Files
Cibello-app/apps/api/src/routes/ops.ts
T
2026-08-12 21:13:39 +07:00

131 lines
4.4 KiB
TypeScript

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<void> => {
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<string, unknown>;
try {
summary = JSON.parse(json) as Record<string, unknown>;
} 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<number>`count(*)`,
promptTokens: sql<number>`coalesce(sum(${geminiUsage.promptTokens}), 0)`,
outputTokens: sql<number>`coalesce(sum(${geminiUsage.outputTokens}), 0)`,
totalTokens: sql<number>`coalesce(sum(${geminiUsage.totalTokens}), 0)`,
costMicrocents: sql<number>`coalesce(sum(${geminiUsage.costMicrocents}), 0)`,
})
.from(geminiUsage)
.where(gte(geminiUsage.createdAt, since24h)),
app.db
.select({
totalTokens: sql<number>`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<string, unknown>).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,
});
});
}