import { timingSafeEqual } from "node:crypto"; import { gte, sql } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import BRAND from "../../../../brand.config.json" with { type: "json" }; import { geminiUsage } from "@app/database"; import { errors } from "../lib/errors.js"; const CACHE_KEY = "ops:summary:cache"; const CACHE_TS_KEY = "ops:summary:computed_at"; function requireOpsToken(app: FastifyInstance) { return async (req: FastifyRequest, reply: FastifyReply): Promise => { if (app.config.NODE_ENV === "production" && req.protocol !== "https") { return reply .status(403) .send({ error: { code: "HTTPS_REQUIRED", message: "Ops-endpointen kräver HTTPS." } }); } const header = req.headers.authorization ?? ""; const match = /^Bearer\s+(.+)$/.exec(header); const provided = match?.[1] ?? ""; const expected = app.config.OPS_TOKEN ?? ""; if (!expected || expected.length < 20 || provided.length !== expected.length) { throw errors.unauthorized(); } const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); if (!ok) throw errors.unauthorized(); }; } /** * Operations summary endpoint: read-only, token-protected, served from cache. * Heavy computation is done by the REFRESH_OPS_SUMMARY_CACHE worker job. */ export async function opsRoutes(app: FastifyInstance) { const preHandler = [requireOpsToken(app)]; app.get("/ops/v1/health", { preHandler }, async () => ({ ok: true, app: "cibello", })); app.get("/ops/v1/summary", { preHandler }, async (_req, reply) => { const [json, ts] = await app.redis.mget(CACHE_KEY, CACHE_TS_KEY); if (!json) { return reply.status(503).send({ error: { code: "CACHE_MISS", message: "Sammanfattningen är inte färdigberäknad än. Vänta på nästa canary-/cache-jobb.", }, }); } let summary: Record; try { summary = JSON.parse(json) as Record; } catch { return reply .status(500) .send({ error: { code: "CACHE_INVALID", message: "Cachen innehåller ogiltig JSON." } }); } const enriched = structuredClone(summary); const geminiDefaults = { anrop_24h: null, prompt_tokens_24h: null, output_tokens_24h: null, total_tokens_24h: null, total_tokens_manad: null, kostnad_usd_mikrocent_24h: null, andel_pa_aamos: 0.0, aamos_agreement: null, }; try { const now = new Date(); const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const [dayAgg, monthAgg] = await Promise.all([ app.db .select({ count: sql`count(*)`, promptTokens: sql`coalesce(sum(${geminiUsage.promptTokens}), 0)`, outputTokens: sql`coalesce(sum(${geminiUsage.outputTokens}), 0)`, totalTokens: sql`coalesce(sum(${geminiUsage.totalTokens}), 0)`, costMicrocents: sql`coalesce(sum(${geminiUsage.costMicrocents}), 0)`, }) .from(geminiUsage) .where(gte(geminiUsage.createdAt, since24h)), app.db .select({ totalTokens: sql`coalesce(sum(${geminiUsage.totalTokens}), 0)`, }) .from(geminiUsage) .where(gte(geminiUsage.createdAt, startOfMonth)), ]); enriched.gemini = { anrop_24h: Number(dayAgg[0]?.count ?? 0), prompt_tokens_24h: Number(dayAgg[0]?.promptTokens ?? 0), output_tokens_24h: Number(dayAgg[0]?.outputTokens ?? 0), total_tokens_24h: Number(dayAgg[0]?.totalTokens ?? 0), total_tokens_manad: Number(monthAgg[0]?.totalTokens ?? 0), kostnad_usd_mikrocent_24h: Number(dayAgg[0]?.costMicrocents ?? 0), andel_pa_aamos: 0.0, aamos_agreement: null, }; } catch { enriched.gemini = geminiDefaults; } if ( enriched.wall && typeof enriched.wall === "object" && Array.isArray((enriched.wall as Record).boards) ) { for (const board of (enriched.wall as { boards: Array<{ title?: string }> }).boards) { if (board.title === "{brand}") board.title = BRAND.name; } } return reply.send({ ...enriched, cached_at: ts ?? summary.as_of, }); }); }