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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import "./setup-env.js";
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { buildServer } from "../src/server.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createDatabase, schema } from "@app/database";
|
||||
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
|
||||
|
||||
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||
const config = loadConfig();
|
||||
|
||||
const queueSummary: OpsQueueSummary = {
|
||||
vantande: 2,
|
||||
aktiva: 1,
|
||||
misslyckade_24h: 0,
|
||||
aldsta_vantande_sek: null,
|
||||
workers_ok: true,
|
||||
};
|
||||
|
||||
describe("/ops/v1/summary", () => {
|
||||
let app: Awaited<ReturnType<typeof buildServer>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildServer(config);
|
||||
await app.ready();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await app.close();
|
||||
await testDb.pool.end();
|
||||
});
|
||||
|
||||
async function cleanup() {
|
||||
await testDb.db.delete(schema.opsSafetyCanary);
|
||||
await testDb.db.delete(schema.productAnalyticsEvents);
|
||||
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
|
||||
}
|
||||
|
||||
it("avvisar anrop utan token", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/ops/v1/summary" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("avvisar felaktig token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ops/v1/summary",
|
||||
headers: { authorization: "Bearer wrong-token" },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("returnerar 503 när cachen saknas", async () => {
|
||||
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ops/v1/summary",
|
||||
headers: { authorization: `Bearer ${config.OPS_TOKEN}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(503);
|
||||
const body = JSON.parse(res.body) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("CACHE_MISS");
|
||||
});
|
||||
|
||||
it("serverar cachad summary med rätt block", async () => {
|
||||
const now = new Date();
|
||||
await testDb.db.insert(schema.productAnalyticsEvents).values([
|
||||
{ eventName: "scan_started", occurredAt: now, properties: {}, userId: null },
|
||||
{
|
||||
eventName: "scan_completed",
|
||||
occurredAt: now,
|
||||
properties: { latencyMs: 120 },
|
||||
userId: null,
|
||||
},
|
||||
{
|
||||
eventName: "scan_completed",
|
||||
occurredAt: now,
|
||||
properties: { latencyMs: 240 },
|
||||
userId: null,
|
||||
},
|
||||
{
|
||||
eventName: "cooking_session_completed",
|
||||
occurredAt: now,
|
||||
properties: {},
|
||||
userId: null,
|
||||
},
|
||||
]);
|
||||
await testDb.db.insert(schema.opsSafetyCanary).values({
|
||||
allergenInvariantBrott: 0,
|
||||
overifieradeVisade: 3,
|
||||
foodSafetyLintAvvisade7d: 1,
|
||||
});
|
||||
|
||||
const summary = await computeOpsSummary({
|
||||
db: testDb.db,
|
||||
budgetUsd: 0,
|
||||
queueSummary,
|
||||
});
|
||||
await app.redis.set("ops:summary:cache", JSON.stringify(summary), "EX", 60);
|
||||
await app.redis.set("ops:summary:computed_at", summary.as_of, "EX", 60);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ops/v1/summary",
|
||||
headers: { authorization: `Bearer ${config.OPS_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body) as {
|
||||
ai_scan: { scans_24h: number; latens_p50_ms: number | null };
|
||||
engagemang: { lagade_maltider_24h: number };
|
||||
sakerhet: { overifierade_visade: number; senaste_kontroll: string | null };
|
||||
jobb: { vantande: number; workers_ok: boolean };
|
||||
cached_at: string;
|
||||
};
|
||||
expect(body.ai_scan.scans_24h).toBe(2);
|
||||
expect(body.ai_scan.latens_p50_ms).toBeGreaterThan(0);
|
||||
expect(body.engagemang.lagade_maltider_24h).toBe(1);
|
||||
expect(body.sakerhet.overifierade_visade).toBe(3);
|
||||
expect(body.sakerhet.senaste_kontroll).toBeTruthy();
|
||||
expect(body.jobb.vantande).toBe(2);
|
||||
expect(body.jobb.workers_ok).toBe(true);
|
||||
expect(body.cached_at).toBe(summary.as_of);
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,10 @@ process.env.AAMOS_MODE = "mock";
|
||||
process.env.EMAIL_MODE = "log";
|
||||
process.env.S3_MODE = "mock";
|
||||
process.env.LOG_LEVEL = "error";
|
||||
process.env.OPS_TOKEN = "test-ops-token-not-for-production";
|
||||
|
||||
// Local test database fallback – tests still need a Postgres instance, but
|
||||
// the connection string is not a secret and the value is predictable.
|
||||
process.env.TEST_DATABASE_URL ||= "postgres://app_user:app_dev_password@localhost:5432/cibello_test";
|
||||
process.env.TEST_DATABASE_URL ||=
|
||||
"postgres://app_user:app_dev_password@localhost:5432/cibello_test";
|
||||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
|
||||
Reference in New Issue
Block a user