feat(ops): /ops/v1/summary med ai/aktivering/engagemang/betalning/jobb/sakerhet + canary

- Nya toppnivåblock i /ops/v1/summary: ai_scan, aktivering, engagemang,
  betalning, jobb, sakerhet. Alla värden läses från DB/cache; null där data
  saknas, inga påhittade värden.
- AI-kostnad i USD mikrocent (native); intäkter fortsatt SEK-öre.
- product_analytics_events är källa för volym, latens, lyckandegrad,
  felfrekvens, aktivering, retention och engagemang.
- Nya händelser: scan_started (API) och scan_failed/scan_completed med
  latencyMs + felkod (worker). latencyMs flödar nu in i scan_completed.
- Safety canary-jobb varje timme: re-härleder allergener för alla recept,
  räknar överifierade publika recept och food-safety-lint; skriver EN rad till
  ops_safety_canary. Endpointen läser endast sista raden.
- Cache-refresh-jobb var 60 s skriver hela summariet till Redis; endpointen
  serverar cachen med 503 vid cache-miss.
- Bearer-token-skydd med OPS_TOKEN; HTTPS-tvång i produktion; ingen PII.
- Tester för endpoint, auth, cache-miss och safety canary.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 05:47:43 +07:00
parent f4a603e977
commit 8f20b2ef1b
27 changed files with 1169 additions and 41 deletions
+9
View File
@@ -52,6 +52,9 @@ const configSchema = z.object({
GEMINI_TIMEOUT_MS: z.coerce.number().int().default(60_000),
GEMINI_DAILY_BUDGET_USD: z.coerce.number().default(0),
/** Strong, rotated ops token for /ops/v1/summary. Set via SSM in production. */
OPS_TOKEN: z.string().min(1).default("dev-ops-token-change-me"),
APP_STORE_MODE: z.enum(["production", "sandbox"]).default("sandbox"),
EMAIL_MODE: z.enum(["log", "smtp"]).default("log"),
SMTP_HOST: z.string().default(""),
@@ -98,6 +101,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
console.error("SÄKERHETSSTOPP: AAMOS_MODE=gemini kräver GEMINI_API_KEY i produktion.");
process.exit(1);
}
if (!cfg.OPS_TOKEN || cfg.OPS_TOKEN.length < 32 || cfg.OPS_TOKEN.startsWith("dev-ops-token")) {
console.error(
"SÄKERHETSSTOPP: OPS_TOKEN måste vara en stark, icke-default hemlighet i produktion.",
);
process.exit(1);
}
}
return cfg;
}
+3 -29
View File
@@ -2,7 +2,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import type { Database } from "@app/database";
import { schema } from "@app/database";
import type { AnalyticsEvent } from "@app/analytics";
import type { DecayProfile } from "@app/inventory-engine";
import type { EventType } from "@app/shared-types";
import type { NewDomainEvent } from "@app/events";
@@ -118,34 +117,9 @@ export async function emitEvent<T extends EventType>(
});
}
/** Track product analytics server-side if user opted in. */
export async function trackProductAnalytics(
db: Database,
userId: string,
event: AnalyticsEvent,
): Promise<void> {
const optedIn = await db
.select({ status: schema.userConsents.status })
.from(schema.userConsents)
.where(and(eq(schema.userConsents.userId, userId), eq(schema.userConsents.kind, "product_analytics")))
.limit(1);
if (optedIn[0] && optedIn[0].status !== "granted") return;
await db.insert(schema.productAnalyticsEvents).values({
occurredAt: event.occurredAt ? new Date(event.occurredAt) : new Date(),
receivedAt: new Date(),
eventName: event.name,
anonymousId: event.anonymousId ?? null,
sessionId: event.sessionId ?? null,
userId,
householdId: event.householdId ?? null,
appVersion: event.appVersion ?? null,
platform: event.platform ?? null,
locale: event.locale ?? null,
experimentVariant: event.experimentVariant ?? null,
properties: event.properties ?? {},
});
}
// Track product analytics server-side if user opted in.
// Implementation lives in @app/database so workers can reuse it.
export { trackProductAnalytics } from "@app/database/analytics";
/** Audit-logg (spec §56). */
export async function audit(
+62
View File
@@ -0,0 +1,62 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
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/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." } });
}
return reply.send({
...summary,
cached_at: ts ?? summary.as_of,
});
});
}
+14 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import { and, eq, gt, isNull } from "drizzle-orm";
import { markMilestone, schema } from "@app/database";
import { markMilestone, schema, trackProductAnalytics } from "@app/database";
import { scanStarted } from "@app/analytics";
import type { JobType, ScanType } from "@app/shared-types";
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
import { errors, parse } from "../lib/errors.js";
@@ -108,6 +109,16 @@ export async function scanRoutes(app: FastifyInstance) {
jobType: job.jobType,
correlationId: req.correlationId,
});
await trackProductAnalytics(app.db, req.userId, {
...scanStarted(),
householdId: job.householdId ?? undefined,
properties: {
scanType: job.scanType,
jobType: job.jobType,
},
});
return { ok: true, status: "queued" };
});
@@ -353,7 +364,8 @@ function extractProposals(job: { result: unknown }): ProposalItem[] {
unit: it.unit ?? null,
bestBeforeDate: it.bestBeforeDate ?? null,
confidence: typeof it.confidence === "number" ? it.confidence : null,
requiresConfirmation: typeof it.requiresConfirmation === "boolean" ? it.requiresConfirmation : null,
requiresConfirmation:
typeof it.requiresConfirmation === "boolean" ? it.requiresConfirmation : null,
}));
}
+2
View File
@@ -30,6 +30,7 @@ import { adminWorkersRoutes } from "./routes/admin-workers.js";
import { analyticsRoutes } from "./routes/analytics.js";
import { onboardingRoutes } from "./routes/onboarding.js";
import { activationRoutes } from "./routes/activation.js";
import { opsRoutes } from "./routes/ops.js";
declare module "fastify" {
interface FastifyInstance {
@@ -98,6 +99,7 @@ export async function buildServer(config: AppConfig) {
await app.register(analyticsRoutes);
await app.register(onboardingRoutes);
await app.register(activationRoutes);
await app.register(opsRoutes);
return app;
}