// API-key-gated routes for other stack apps (currently `reports`, for the // Directors' report) — not JWT/cookie auth. Mirrors the forecasting <-> reports // internal API pattern (X-API-Key header, static key from env). import { pool } from '../db.js' import { getPeriodConsumption, getTariffForMeter, getRateWindows, computeCost, getTrailingDailyRate, resolvePeriod, daysInclusive, daysBetween, } from '../lib/cost-calc.js' async function requireApiKey(req, reply) { const key = req.headers['x-api-key'] if (!process.env.UTILITIES_API_KEY || key !== process.env.UTILITIES_API_KEY) { return reply.status(401).send({ error: 'Invalid or missing API key' }) } } async function resolveCategoryFilter(category) { if (!category) return null const { rows } = await pool.query( 'SELECT id FROM meter_categories WHERE key = $1 OR id::text = $1', [category] ) return rows[0]?.id ?? null } export async function internalRoutes(app) { app.addHook('preHandler', requireApiKey) // GET /api/internal/readings?period=YYYY-MM&category= — raw reading summary app.get('/api/internal/readings', async (req) => { const { start, end } = resolvePeriod(req.query.period) const categoryId = await resolveCategoryFilter(req.query.category) const conditions = ['m.active = TRUE'] const params = [] if (categoryId) { params.push(categoryId); conditions.push(`m.category_id = $${params.length}`) } const { rows: meters } = await pool.query( `SELECT m.id, m.name, m.category_id, c.key AS category_key, c.name AS category_name, c.unit_label FROM meters m JOIN meter_categories c ON c.id = m.category_id WHERE ${conditions.join(' AND ')} ORDER BY c.sort_order, m.name`, params ) const readings = [] for (const m of meters) { const { consumption, first, last, has_data } = await getPeriodConsumption(m.id, start, end) readings.push({ meter_id: m.id, meter_name: m.name, category: m.category_key, category_name: m.category_name, unit_label: m.unit_label, first_reading: first, last_reading: last, consumption, has_data, }) } return { period: { start, end }, readings } }) // GET /api/internal/costs?period=YYYY-MM — cost breakdown by category app.get('/api/internal/costs', async (req) => { const { start, end } = resolvePeriod(req.query.period) const daysInPeriod = daysInclusive(start, end) const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') const breakdown = [] const totals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } for (const cat of categories) { const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) const catTotals = { usage_cost_pence: 0, standing_cost_pence: 0, ccl_cost_pence: 0, vat_pence: 0, total_pence: 0, consumption: 0 } for (const m of meters) { const { consumption, has_data } = await getPeriodConsumption(m.id, start, end) const tariff = await getTariffForMeter(m.id, end) const windows = tariff ? await getRateWindows(tariff.id) : [] const cost = computeCost({ tariff, windows, consumption, daysInPeriod }) if (has_data) catTotals.consumption += consumption catTotals.usage_cost_pence += cost.usage_cost_pence catTotals.standing_cost_pence += cost.standing_cost_pence catTotals.ccl_cost_pence += cost.ccl_cost_pence catTotals.vat_pence += cost.vat_pence catTotals.total_pence += cost.total_pence } breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, ...catTotals }) for (const k of Object.keys(totals)) totals[k] += catTotals[k] } return { period: { start, end, days_in_period: daysInPeriod }, categories: breakdown, totals } }) // GET /api/internal/estimate?period=current — projected cost for the open period app.get('/api/internal/estimate', async (req) => { const { start, end, year, month } = resolvePeriod(req.query.period) const today = new Date().toISOString().slice(0, 10) const asOfDate = today < end ? today : end const daysInPeriod = daysInclusive(start, end) const remainingDays = Math.max(daysBetween(asOfDate, end), 0) const { rows: config } = await pool.query("SELECT value FROM config WHERE key = 'estimate_trailing_days'") const globalDefault = config[0]?.value ?? 30 const { rows: categories } = await pool.query('SELECT * FROM meter_categories WHERE active = TRUE ORDER BY sort_order') const breakdown = [] const totals = { total_pence: 0, projected_consumption: 0 } for (const cat of categories) { const windowDays = cat.estimate_trailing_days || globalDefault const { rows: meters } = await pool.query('SELECT id FROM meters WHERE category_id = $1 AND active = TRUE', [cat.id]) let catConsumption = 0 let catTotalPence = 0 for (const m of meters) { const { daily_rate } = await getTrailingDailyRate(m.id, windowDays) const { consumption: actualToDate, has_data } = await getPeriodConsumption(m.id, start, asOfDate) let projected = null if (daily_rate != null) projected = (has_data ? actualToDate : 0) + daily_rate * remainingDays else if (has_data) projected = actualToDate const tariff = await getTariffForMeter(m.id, asOfDate) const windows = tariff ? await getRateWindows(tariff.id) : [] const cost = computeCost({ tariff, windows, consumption: projected, daysInPeriod }) if (projected != null) catConsumption += projected catTotalPence += cost.total_pence } breakdown.push({ category: cat.key, category_name: cat.name, unit_label: cat.unit_label, projected_consumption: catConsumption, total_pence: catTotalPence }) totals.projected_consumption += catConsumption totals.total_pence += catTotalPence } return { period: { year, month, start, end, remaining_days: remainingDays }, categories: breakdown, totals } }) }