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:
commit
f977a5778f
36 changed files with 8125 additions and 0 deletions
59
backend/src/auth.js
Normal file
59
backend/src/auth.js
Normal 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
54
backend/src/db.js
Normal 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
30
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
58
backend/src/lib/icons.js
Normal 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'
|
||||
}
|
||||
10
backend/src/lib/scheduler.js
Normal file
10
backend/src/lib/scheduler.js
Normal 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)
|
||||
}
|
||||
268
backend/src/lib/weather-fetch.js
Normal file
268
backend/src/lib/weather-fetch.js
Normal 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}`)
|
||||
}
|
||||
}
|
||||
78
backend/src/routes/forecast.js
Normal file
78
backend/src/routes/forecast.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue