Scaffold weather app: reception forecast display + shared internal API

- Backend: Fastify service polling Met Office DataHub (Global Spot: hourly,
  three-hourly, daily endpoints) with Open-Meteo fallback, hourly refresh.
  Reuses settings' hotel_location/met_office integrations (same pattern as
  hvac's lib/weather.js) rather than its own credential copy.
- Three display tiers stored in one forecast_points table: today (hourly),
  next 2 days (3-hourly), days 4-7 (plain daily) - 7 days total.
- Public /api/public/forecast (no auth) feeds the reception kiosk display;
  /api/internal/forecast (WEATHER_SECRET bearer) lets other apps read the
  stored forecast instead of polling providers themselves; /api/admin/*
  (normal JWT + settings cap) is the staff status/refresh page.
- Frontend: root / is the public full-screen Display (no AuthGate at all -
  first genuinely public app in the stack); /admin is the authed status
  page. apps.base_path is /weather/admin so the portal card opens the
  admin view directly, precedented by forecasting's history sub-route.
- LXC 131 / 10.10.10.131, registered in auth/src/db.js, push-to-forgejo.sh,
  and stack-init/install-stack.sh (deploy_weather + --only dispatch + full
  install chain).
- Typechecks and builds clean; not yet deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-08-25 12:22:53 +00:00
commit f977a5778f
36 changed files with 8125 additions and 0 deletions

59
backend/src/auth.js Normal file
View file

@ -0,0 +1,59 @@
import { jwtVerify } from 'jose'
import { isOnsite } from './ip-check.js'
const APP_SLUG = process.env.APP_SLUG || 'weather'
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
// Only the /admin surface uses this — the reception display itself is deliberately
// public (see routes/forecast.js's publicForecastRoutes), so this only gates the
// staff status/refresh page.
export async function requireAuth(request, reply) {
const token = request.cookies?.hnf_session
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
let payload
try {
const { payload: p } = await jwtVerify(token, secret)
payload = p
} catch {
return reply.status(401).send({ error: 'Invalid session' })
}
if (!payload.apps?.includes(APP_SLUG)) {
return reply.status(403).send({ error: 'No permission for this app' })
}
if (!payload.offsite_allowed) {
const clientIP = request.headers['x-real-ip'] || request.ip
if (!(await isOnsite(clientIP))) {
return reply.status(403).send({ error: 'Access restricted to site network' })
}
}
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
caps = ['use']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

54
backend/src/db.js Normal file
View file

@ -0,0 +1,54 @@
import pg from 'pg'
const { Pool } = pg
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
export async function initDb() {
await pool.query(`
-- App-wide key/value status (last fetch time/provider/error) — same KV pattern as
-- tasks' config table, just used for status rather than user-facing settings since
-- the real settings (hotel_location, met_office key) live centrally in the
-- shared 'settings' app, not here.
CREATE TABLE IF NOT EXISTS status (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One row per forecast data point. 'resolution' is which display tier it belongs
-- to: 'hourly' (today, full day), '3hourly' (next 2 days), 'daily' (days 4-7).
-- temp_min_c only populated for 'daily' rows (hourly/3hourly are point-in-time).
-- Each refresh cycle replaces the full set for a resolution in one transaction
-- (see lib/weather-fetch.js) rather than patching individual rows, so stale points
-- from a provider that later drops out of range never linger.
CREATE TABLE IF NOT EXISTS forecast_points (
id SERIAL PRIMARY KEY,
resolution TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
temp_c NUMERIC NOT NULL,
temp_min_c NUMERIC,
feels_like_c NUMERIC,
precip_prob NUMERIC,
wind_speed_mph NUMERIC,
icon TEXT NOT NULL,
condition_text TEXT,
source TEXT NOT NULL,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (resolution, ts)
);
CREATE INDEX IF NOT EXISTS forecast_points_res_ts_idx ON forecast_points (resolution, ts);
`)
}
export async function getStatus() {
const { rows } = await pool.query('SELECT key, value FROM status')
return Object.fromEntries(rows.map(r => [r.key, r.value]))
}
export async function setStatus(key, value) {
await pool.query(
`INSERT INTO status (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, JSON.stringify(value)]
)
}

30
backend/src/index.js Normal file
View file

@ -0,0 +1,30 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import { initDb } from './db.js'
import { startScheduler } from './lib/scheduler.js'
import { publicForecastRoutes, internalForecastRoutes, adminForecastRoutes } from './routes/forecast.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
await app.register(cookie)
await app.register(cors, {
origin: process.env.CORS_ORIGIN || false,
credentials: true,
})
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
await app.register(publicForecastRoutes)
await app.register(internalForecastRoutes)
await app.register(adminForecastRoutes)
try {
await initDb()
startScheduler(app)
await app.listen({ port: 3001, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)
}

80
backend/src/ip-check.js Normal file
View file

@ -0,0 +1,80 @@
import dns from 'dns/promises'
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
const TTL = 5 * 60 * 1000
const cache = new Map()
const PUBLIC_IP_URLS = [
'https://api.ipify.org',
'https://ifconfig.co/ip',
'https://icanhazip.com',
]
function normalizeIP(ip) {
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
}
function isIPv4(s) {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
}
function ipInCidr(ip, cidr) {
const [range, bits] = cidr.split('/')
if (!isIPv4(ip) || !isIPv4(range)) return false
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
return (toInt(ip) & mask) === (toInt(range) & mask)
}
async function fetchPublicIP() {
for (const url of PUBLIC_IP_URLS) {
try {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 4000)
const res = await fetch(url, { signal: ctrl.signal })
clearTimeout(timer)
if (!res.ok) continue
const ip = (await res.text()).trim()
if (isIPv4(ip)) return ip
} catch {
// try next
}
}
return null
}
async function resolveDynamic(key, resolver) {
const hit = cache.get(key)
if (hit && Date.now() < hit.expiry) return hit.ip
const ip = await resolver()
if (ip) {
cache.set(key, { ip, expiry: Date.now() + TTL })
return ip
}
return hit ? hit.ip : null
}
export async function isOnsite(requestIP) {
if (matchers.length === 0 || matchers.includes('disabled')) return true
const ip = normalizeIP(requestIP)
if (!ip) return false
for (const m of matchers) {
if (m === 'auto') {
const pub = await resolveDynamic('auto', fetchPublicIP)
if (pub && ip === pub) return true
} else if (m.includes('/')) {
if (ipInCidr(ip, m)) return true
} else if (/[a-zA-Z]/.test(m)) {
const resolved = await resolveDynamic(m, async () => {
try { return (await dns.resolve4(m))[0] } catch { return null }
})
if (resolved && ip === resolved) return true
} else {
if (ip === m) return true
}
}
return false
}

58
backend/src/lib/icons.js Normal file
View file

@ -0,0 +1,58 @@
// Normalizes each provider's own weather-condition code into one shared vocabulary
// so routes/frontend never need to know which provider a point came from. Frontend
// maps these keys to Lucide icons (see frontend/src/lib/icons.tsx) — keep the two
// vocabularies in sync if you add a key here.
export const ICON_KEYS = [
'clear-day', 'clear-night',
'partly-cloudy-day', 'partly-cloudy-night',
'cloudy', 'overcast', 'fog',
'drizzle', 'rain-light', 'rain-heavy',
'sleet', 'hail',
'snow-light', 'snow-heavy',
'thunder',
]
// Met Office DataHub "significantWeatherCode" table (0-30). Documented at
// https://datahub.metoffice.gov.uk — day/night only differentiate for the
// clear/partly-cloudy/shower codes; everything else (mist, rain, snow, thunder...)
// is a single code regardless of time of day. Same code table across the hourly,
// three-hourly and daily endpoints.
const MET_OFFICE_MAP = {
0: 'clear-night', 1: 'clear-day',
2: 'partly-cloudy-night', 3: 'partly-cloudy-day',
5: 'fog', 6: 'fog',
7: 'cloudy', 8: 'overcast',
9: 'rain-light', 10: 'rain-light', 11: 'drizzle', 12: 'rain-light',
13: 'rain-heavy', 14: 'rain-heavy', 15: 'rain-heavy',
16: 'sleet', 17: 'sleet', 18: 'sleet',
19: 'hail', 20: 'hail', 21: 'hail',
22: 'snow-light', 23: 'snow-light', 24: 'snow-light',
25: 'snow-heavy', 26: 'snow-heavy', 27: 'snow-heavy',
28: 'thunder', 29: 'thunder', 30: 'thunder',
}
export function iconFromMetOffice(code) {
return MET_OFFICE_MAP[code] ?? 'cloudy'
}
// Open-Meteo uses the WMO weather-interpretation code table (0-99), which is
// time-of-day agnostic — pass the point's local hour so clear/partly-cloudy can
// still pick a day/night variant. hour outside [6,20) is treated as night; no
// real sunrise/sunset calc, close enough for an icon choice.
export function iconFromOpenMeteo(code, hour) {
const isNight = hour < 6 || hour >= 20
if (code === 0) return isNight ? 'clear-night' : 'clear-day'
if (code === 1 || code === 2) return isNight ? 'partly-cloudy-night' : 'partly-cloudy-day'
if (code === 3) return 'overcast'
if (code === 45 || code === 48) return 'fog'
if (code === 51 || code === 53 || code === 55) return 'drizzle'
if (code === 56 || code === 57) return 'sleet'
if (code === 61 || code === 63 || code === 80) return 'rain-light'
if (code === 65 || code === 81 || code === 82) return 'rain-heavy'
if (code === 66 || code === 67) return 'sleet'
if (code === 71 || code === 73 || code === 77 || code === 85) return 'snow-light'
if (code === 75 || code === 86) return 'snow-heavy'
if (code === 96 || code === 99) return 'thunder'
if (code === 95) return 'thunder'
return 'cloudy'
}

View file

@ -0,0 +1,10 @@
import { refreshForecast } from './weather-fetch.js'
// "Updates hourly" per the spec — forecasts don't move fast enough to justify polling
// more often, and it keeps both providers well inside any rate limit.
const REFRESH_INTERVAL_MS = 60 * 60 * 1000
export function startScheduler(app) {
refreshForecast(app.log).catch(err => app.log.error(err))
setInterval(() => refreshForecast(app.log).catch(err => app.log.error(err)), REFRESH_INTERVAL_MS)
}

View file

@ -0,0 +1,268 @@
// Fetches the reception forecast (today hourly, next-2-days 3-hourly, days 4-7 daily)
// and stores it in forecast_points. Reuses the same settings-app integrations hvac
// already uses (hotel_location + met_office, via SETTINGS_URL/SETTINGS_SECRET) rather
// than asking for its own copy of the credentials — see lib/weather.js in hvac.
//
// Provider selection: Met Office DataHub Global Spot if an api_key is configured and
// the integration is enabled; otherwise, or if any Met Office call fails, Open-Meteo
// (no key required). Same fail-soft posture as hvac's getPeakTempC() — never throw
// out to the scheduler, just record the error in `status` and keep serving whatever
// was fetched last time.
//
// IMPORTANT: only the daily endpoint (sitespecific/v0/point/daily) has been exercised
// against a live Met Office key so far (hvac, 2026-08-25 — see project_hvac memory's
// "Live subscription mismatch" note). The hourly and three-hourly endpoint paths and
// field names below are taken from the published Met Office DataHub Site Specific
// Forecast API docs but NOT yet verified live — if either 403s or comes back with
// unexpected fields, it'll throw and this whole refresh degrades to Open-Meteo (see
// the catch in refreshForecast), so a doc mismatch here fails safe, it just won't use
// Met Office data until the field names are corrected against a real response.
import { pool, setStatus } from '../db.js'
import { iconFromMetOffice, iconFromOpenMeteo } from './icons.js'
const MET_OFFICE_BASE = 'https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point'
async function getIntegration(slug) {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/${slug}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (res.status === 404 || res.status === 403) return null
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching ${slug}`)
return res.json()
}
function londonDateKey(isoTime) {
// en-CA gives YYYY-MM-DD, which sorts/compares correctly as a plain string.
return new Date(isoTime).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })
}
function londonHour(isoTime) {
return parseInt(new Date(isoTime).toLocaleString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', hour12: false }))
}
// ── Met Office DataHub (Global Spot) ────────────────────────────────────────
async function fetchMetOfficeTimestep(timestep, lat, lon, apiKey) {
const res = await fetch(
`${MET_OFFICE_BASE}/${timestep}?latitude=${lat}&longitude=${lon}`,
{ headers: { apikey: apiKey }, signal: AbortSignal.timeout(8000) }
)
if (!res.ok) throw new Error(`Met Office DataHub (${timestep}) returned ${res.status}`)
const data = await res.json()
const series = data?.features?.[0]?.properties?.timeSeries
if (!Array.isArray(series)) throw new Error(`Met Office DataHub (${timestep}) response missing timeSeries`)
return series
}
async function fetchFromMetOffice(lat, lon, apiKey) {
const [hourly, threeHourly, daily] = await Promise.all([
fetchMetOfficeTimestep('hourly', lat, lon, apiKey),
fetchMetOfficeTimestep('three-hourly', lat, lon, apiKey),
fetchMetOfficeTimestep('daily', lat, lon, apiKey),
])
const today = londonDateKey(new Date().toISOString())
const hourlyPoints = hourly
.filter(p => londonDateKey(p.time) === today)
.map(p => ({
resolution: 'hourly',
ts: p.time,
temp_c: p.screenTemperature,
temp_min_c: null,
feels_like_c: p.feelsLikeTemperature ?? null,
precip_prob: p.probOfPrecipitation ?? null,
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null, // m/s -> mph
icon: iconFromMetOffice(p.significantWeatherCode),
source: 'met_office',
}))
.filter(p => typeof p.temp_c === 'number')
// three-hourly covers today too; keep only the next two calendar days.
const dayKeys = [...new Set(threeHourly.map(p => londonDateKey(p.time)))].filter(d => d !== today).slice(0, 2)
const threeHourlyPoints = threeHourly
.filter(p => dayKeys.includes(londonDateKey(p.time)))
.map(p => ({
resolution: '3hourly',
ts: p.time,
temp_c: p.maxScreenAirTemp ?? p.screenTemperature,
temp_min_c: p.minScreenAirTemp ?? null,
feels_like_c: p.feelsLikeTemperature ?? null,
precip_prob: p.probOfPrecipitation ?? null,
wind_speed_mph: p.windSpeed10m != null ? p.windSpeed10m * 2.237 : null,
icon: iconFromMetOffice(p.significantWeatherCode),
source: 'met_office',
}))
.filter(p => typeof p.temp_c === 'number')
// daily covers ~7 days; skip today + the 2 days already covered by three-hourly, take next 4.
const skipKeys = new Set([today, ...dayKeys])
const dailyPoints = daily
.filter(p => !skipKeys.has(londonDateKey(p.time)))
.slice(0, 4)
.map(p => ({
resolution: 'daily',
ts: p.time,
temp_c: p.dayMaxScreenTemperature,
temp_min_c: p.nightMinScreenTemperature ?? null,
feels_like_c: null,
precip_prob: p.dayProbabilityOfPrecipitation ?? null,
wind_speed_mph: p.midday10MWindSpeed != null ? p.midday10MWindSpeed * 2.237 : null,
icon: iconFromMetOffice(p.daySignificantWeatherCode),
source: 'met_office',
}))
.filter(p => typeof p.temp_c === 'number')
return { hourlyPoints, threeHourlyPoints, dailyPoints }
}
// ── Open-Meteo fallback (no key required) ───────────────────────────────────
async function fetchFromOpenMeteo(lat, lon) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}` +
`&hourly=temperature_2m,apparent_temperature,precipitation_probability,weathercode,windspeed_10m` +
`&daily=temperature_2m_max,temperature_2m_min,weathercode` +
`&timezone=Europe%2FLondon&forecast_days=8`
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) throw new Error(`Open-Meteo returned ${res.status}`)
const data = await res.json()
if (!data?.hourly?.time || !data?.daily?.time) throw new Error('Open-Meteo response missing hourly/daily blocks')
const today = londonDateKey(new Date().toISOString())
const hourlyRows = data.hourly.time.map((t, i) => ({
time: t,
dateKey: londonDateKey(t),
hour: londonHour(t),
temp: data.hourly.temperature_2m[i],
feelsLike: data.hourly.apparent_temperature?.[i] ?? null,
precipProb: data.hourly.precipitation_probability?.[i] ?? null,
code: data.hourly.weathercode[i],
wind: data.hourly.windspeed_10m?.[i] ?? null,
}))
const hourlyPoints = hourlyRows
.filter(r => r.dateKey === today)
.map(r => ({
resolution: 'hourly',
ts: r.time,
temp_c: r.temp,
temp_min_c: null,
feels_like_c: r.feelsLike,
precip_prob: r.precipProb,
wind_speed_mph: r.wind != null ? r.wind * 0.621 : null, // km/h -> mph
icon: iconFromOpenMeteo(r.code, r.hour),
source: 'open_meteo',
}))
const nextDayKeys = [...new Set(hourlyRows.map(r => r.dateKey))].filter(d => d !== today).slice(0, 2)
const threeHourlyPoints = hourlyRows
.filter(r => nextDayKeys.includes(r.dateKey) && r.hour % 3 === 0)
.map(r => ({
resolution: '3hourly',
ts: r.time,
temp_c: r.temp,
temp_min_c: null,
feels_like_c: r.feelsLike,
precip_prob: r.precipProb,
wind_speed_mph: r.wind != null ? r.wind * 0.621 : null,
icon: iconFromOpenMeteo(r.code, r.hour),
source: 'open_meteo',
}))
const skipKeys = new Set([today, ...nextDayKeys])
const dailyPoints = data.daily.time
.map((t, i) => ({
time: t,
dateKey: londonDateKey(`${t}T12:00:00`),
max: data.daily.temperature_2m_max[i],
min: data.daily.temperature_2m_min[i],
code: data.daily.weathercode[i],
}))
.filter(r => !skipKeys.has(r.dateKey))
.slice(0, 4)
.map(r => ({
resolution: 'daily',
ts: `${r.time}T12:00:00Z`,
temp_c: r.max,
temp_min_c: r.min,
feels_like_c: null,
precip_prob: null,
wind_speed_mph: null,
icon: iconFromOpenMeteo(r.code, 12),
source: 'open_meteo',
}))
return { hourlyPoints, threeHourlyPoints, dailyPoints }
}
// ── Storage ──────────────────────────────────────────────────────────────
async function replaceResolution(resolution, points) {
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query('DELETE FROM forecast_points WHERE resolution = $1', [resolution])
for (const p of points) {
await client.query(
`INSERT INTO forecast_points
(resolution, ts, temp_c, temp_min_c, feels_like_c, precip_prob, wind_speed_mph, icon, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (resolution, ts) DO UPDATE SET
temp_c = EXCLUDED.temp_c, temp_min_c = EXCLUDED.temp_min_c,
feels_like_c = EXCLUDED.feels_like_c, precip_prob = EXCLUDED.precip_prob,
wind_speed_mph = EXCLUDED.wind_speed_mph, icon = EXCLUDED.icon,
source = EXCLUDED.source, fetched_at = NOW()`,
[p.resolution, p.ts, p.temp_c, p.temp_min_c, p.feels_like_c, p.precip_prob, p.wind_speed_mph, p.icon, p.source]
)
}
await client.query('COMMIT')
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
// refreshForecast() — the scheduler's hourly entrypoint. Never throws; records
// success/failure in `status` so the admin page can show it, same fail-safe posture
// as hvac's weather compensation (missing config or a dead provider just means the
// display keeps showing the last good fetch, never a blank/broken screen).
export async function refreshForecast(log = console) {
try {
const location = await getIntegration('hotel_location')
const lat = location?.latitude
const lon = location?.longitude
if (!lat || !lon) {
await setStatus('last_error', 'hotel_location not configured in settings')
log.warn?.('[weather] hotel_location not configured — skipping refresh')
return
}
const metOffice = await getIntegration('met_office')
let result, provider
if (metOffice?.api_key) {
try {
result = await fetchFromMetOffice(lat, lon, metOffice.api_key)
provider = 'met_office'
} catch (err) {
log.error?.(`[weather] Met Office fetch failed, falling back to Open-Meteo: ${err.message}`)
result = await fetchFromOpenMeteo(lat, lon)
provider = 'open_meteo'
}
} else {
result = await fetchFromOpenMeteo(lat, lon)
provider = 'open_meteo'
}
await replaceResolution('hourly', result.hourlyPoints)
await replaceResolution('3hourly', result.threeHourlyPoints)
await replaceResolution('daily', result.dailyPoints)
await setStatus('last_fetch_at', new Date().toISOString())
await setStatus('last_provider', provider)
await setStatus('last_error', null)
log.info?.(`[weather] refreshed via ${provider}: ${result.hourlyPoints.length} hourly, ${result.threeHourlyPoints.length} 3-hourly, ${result.dailyPoints.length} daily`)
} catch (err) {
await setStatus('last_error', err.message)
log.error?.(`[weather] refresh failed: ${err.message}`)
}
}

View file

@ -0,0 +1,78 @@
import { pool, getStatus } from '../db.js'
import { requireAuth, requireCap } from '../auth.js'
import { refreshForecast } from '../lib/weather-fetch.js'
function rowToPoint(r) {
return {
ts: r.ts,
temp_c: Number(r.temp_c),
temp_min_c: r.temp_min_c != null ? Number(r.temp_min_c) : null,
feels_like_c: r.feels_like_c != null ? Number(r.feels_like_c) : null,
precip_prob: r.precip_prob != null ? Number(r.precip_prob) : null,
wind_speed_mph: r.wind_speed_mph != null ? Number(r.wind_speed_mph) : null,
icon: r.icon,
}
}
async function buildPayload() {
const { rows } = await pool.query(
`SELECT * FROM forecast_points ORDER BY ts ASC`
)
const status = await getStatus()
return {
hourly: rows.filter(r => r.resolution === 'hourly').map(rowToPoint),
threeHourly: rows.filter(r => r.resolution === '3hourly').map(rowToPoint),
daily: rows.filter(r => r.resolution === 'daily').map(rowToPoint),
meta: {
fetched_at: status.last_fetch_at ?? null,
provider: status.last_provider ?? null,
},
}
}
// Public, unauthenticated — this is the whole point of the app: a reception lobby
// display can't reasonably sit behind a login screen. No PII, no write actions, just
// read-only forecast data, so it deliberately skips requireAuth entirely (see
// project_weather memory's access-model decision).
export async function publicForecastRoutes(app) {
app.get('/api/public/forecast', async () => buildPayload())
}
// Service-to-service — same bearer-secret convention as settings' /internal/*
// routes, for other apps (e.g. a future hvac refactor) to read the stored forecast
// instead of polling Met Office/Open-Meteo themselves.
export async function internalForecastRoutes(app) {
app.get('/api/internal/forecast', async (req, reply) => {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.WEATHER_SECRET) {
return reply.status(401).send({ error: 'Unauthorized' })
}
return buildPayload()
})
}
// Staff-only status/refresh page — normal internal JWT auth, gated behind the
// 'settings' capability (see seed-app.js — same slug every app uses for "configure
// this app's own settings").
export async function adminForecastRoutes(app) {
app.get('/api/admin/status', { preHandler: [requireAuth, requireCap('settings')] }, async () => {
const status = await getStatus()
const { rows } = await pool.query(
`SELECT resolution, COUNT(*)::int AS count FROM forecast_points GROUP BY resolution`
)
const counts = Object.fromEntries(rows.map(r => [r.resolution, r.count]))
return {
last_fetch_at: status.last_fetch_at ?? null,
last_provider: status.last_provider ?? null,
last_error: status.last_error ?? null,
point_counts: { hourly: counts.hourly ?? 0, threeHourly: counts['3hourly'] ?? 0, daily: counts.daily ?? 0 },
}
})
app.post('/api/admin/refresh', { preHandler: [requireAuth, requireCap('settings')] }, async (req, reply) => {
await refreshForecast(app.log)
const status = await getStatus()
if (status.last_error) return reply.status(502).send({ error: status.last_error })
return { ok: true, last_fetch_at: status.last_fetch_at, provider: status.last_provider }
})
}