Fastify + pg backend, React/TS/Vite frontend. Categories (electric, gas, oil, water), meters with sub-metering rollup, tariffs with time-of-use rate windows, standing charges, Climate Change Levy and VAT, manual reading entry, consumption/cost reports, period cost estimates, and an API-key-gated /api/internal/* surface for the reports app's Directors Report. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
135 lines
6.1 KiB
JavaScript
135 lines
6.1 KiB
JavaScript
// 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 }
|
|
})
|
|
}
|