131 lines
4.4 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|