Phase 1 ops core: base blocks, feedback, canary fixes

- Add app/ekonomi/anvandare/prenumerationer/butik/feedback blocks to computeOpsSummary.
- New feedback table, POST /api/feedback, GDPR erasure wiring.
- Canary: only count unverified/rejected published recipes; treat verified/editorial as safe.
- Add 'rejected' recipe verification status enum value.
- Fix food-safety lint baseline: remove egg/cured fish from raw-protein list, relax
  genomstekt regex, add safe-cooking phrases to seed recipes.
- Seed recipes now get verificationStatus=editorial.
- Tests: ops summary blocks, feedback route, me.residual cleanup.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 06:35:51 +07:00
parent 8f20b2ef1b
commit 4687f46384
16 changed files with 606 additions and 44 deletions
+84 -3
View File
@@ -2,6 +2,7 @@ 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 { sql } from "drizzle-orm";
import { createDatabase, schema } from "@app/database";
import { computeOpsSummary, type OpsQueueSummary } from "@app/database/ops-summary";
@@ -16,6 +17,13 @@ const queueSummary: OpsQueueSummary = {
workers_ok: true,
};
const planPrices = {
free: 0,
household: 9900,
family: 14900,
large_household: 19900,
};
describe("/ops/v1/summary", () => {
let app: Awaited<ReturnType<typeof buildServer>>;
@@ -34,6 +42,7 @@ describe("/ops/v1/summary", () => {
async function cleanup() {
await testDb.db.delete(schema.opsSafetyCanary);
await testDb.db.delete(schema.productAnalyticsEvents);
await testDb.db.delete(schema.feedback);
await app.redis.del("ops:summary:cache", "ops:summary:computed_at");
}
@@ -85,17 +94,58 @@ describe("/ops/v1/summary", () => {
properties: {},
userId: null,
},
{
eventName: "account_created",
occurredAt: now,
properties: {},
userId: null,
},
{
eventName: "account_created",
occurredAt: now,
properties: {},
userId: null,
},
{
eventName: "recommendations_viewed",
occurredAt: now,
properties: {},
userId: null,
},
{
eventName: "recommendation_opened",
occurredAt: now,
properties: {},
userId: null,
},
]);
await testDb.db.insert(schema.opsSafetyCanary).values({
allergenInvariantBrott: 0,
overifieradeVisade: 3,
foodSafetyLintAvvisade7d: 1,
overifieradeVisade: 0,
foodSafetyLintAvvisade7d: 0,
});
await testDb.db.insert(schema.feedback).values([
{
userId: null,
typ: "bug",
rubrik: "Knappen fungerar inte",
text: "Detaljerad text ska aldrig visas i summary.",
status: "oppen",
},
{
userId: null,
typ: "onskemal",
rubrik: "Mörkt läge",
text: "Önskemålstext ska aldrig visas i summary.",
status: "stangd",
},
]);
const summary = await computeOpsSummary({
db: testDb.db,
budgetUsd: 0,
queueSummary,
planPrices,
});
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);
@@ -108,16 +158,47 @@ describe("/ops/v1/summary", () => {
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as {
app: { app: string; generated_at: string };
ekonomi: { mrr: number | null; intakt_24h: number | null; valuta: string };
anvandare: { aktiva_nu: number; dau: number; mau: number; nya_24h: number; nya_7d: number };
prenumerationer: { trial_aktiva: number; betalande: number };
butik: { butik: null };
feedback: { oppna: number; nya_24h: number; senaste: Array<unknown> };
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.app.app).toBe("cibello");
expect(body.app.generated_at).toBeTruthy();
expect(body.ekonomi.valuta).toBe("SEK");
expect(body.anvandare.nya_24h).toBe(2);
expect(body.anvandare.dau).toBe(0);
const payingNow = await testDb.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.subscriptions)
.where(
sql`${schema.subscriptions.status} = 'active' AND (${schema.subscriptions.expiresAt} IS NULL OR ${schema.subscriptions.expiresAt} > now())`,
);
expect(body.prenumerationer.betalande).toBe(Number(payingNow[0]?.count ?? 0));
const trialsNow = await testDb.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.trials)
.where(sql`${schema.trials.endsAt} >= now()`);
expect(body.prenumerationer.trial_aktiva).toBe(Number(trialsNow[0]?.count ?? 0));
expect(body.butik.butik).toBeNull();
expect(body.feedback.oppna).toBe(1);
expect(body.feedback.nya_24h).toBe(2);
expect(body.feedback.senaste.length).toBe(2);
expect(body.feedback.senaste[0]).not.toHaveProperty("text");
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.overifierade_visade).toBe(0);
expect(body.sakerhet.senaste_kontroll).toBeTruthy();
expect(body.jobb.vantande).toBe(2);
expect(body.jobb.workers_ok).toBe(true);