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
+48
View File
@@ -0,0 +1,48 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { schema, trackProductAnalytics } from "@app/database";
import { feedbackSubmitted } from "@app/analytics";
import { errors, parse } from "../lib/errors.js";
const createFeedbackSchema = z.object({
typ: z.enum(["bug", "onskemal"]),
rubrik: z.string().min(1).max(200),
text: z.string().min(1).max(5000),
plattform: z.enum(["ios", "android", "web"]).optional(),
appVersion: z.string().max(32).optional(),
});
/**
* POST /api/feedback användarfeedback (doc §2).
* Skapar en öppen ticket och spårar ett anonymiserat analytics-event.
* Själva text-kroppen visas aldrig i /ops/v1/summary.
*/
export async function feedbackRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.post("/api/feedback", auth, async (req) => {
const body = parse(createFeedbackSchema, req.body);
const [row] = await app.db
.insert(schema.feedback)
.values({
userId: req.userId,
typ: body.typ,
rubrik: body.rubrik,
text: body.text,
status: "oppen",
plattform: body.plattform ?? null,
appVersion: body.appVersion ?? null,
})
.returning();
if (!row) throw errors.internal("Kunde inte skapa feedback.");
await trackProductAnalytics(app.db, req.userId, {
...feedbackSubmitted(),
properties: { typ: body.typ },
});
return { id: row.id, status: row.status };
});
}
+2
View File
@@ -31,6 +31,7 @@ import { analyticsRoutes } from "./routes/analytics.js";
import { onboardingRoutes } from "./routes/onboarding.js";
import { activationRoutes } from "./routes/activation.js";
import { opsRoutes } from "./routes/ops.js";
import { feedbackRoutes } from "./routes/feedback.js";
declare module "fastify" {
interface FastifyInstance {
@@ -100,6 +101,7 @@ export async function buildServer(config: AppConfig) {
await app.register(onboardingRoutes);
await app.register(activationRoutes);
await app.register(opsRoutes);
await app.register(feedbackRoutes);
return app;
}
+75
View File
@@ -0,0 +1,75 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { eq } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, schema } from "@app/database";
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
describe("POST /api/feedback", () => {
let app: Awaited<ReturnType<typeof buildServer>>;
const email = "feedback-test@example.invalid";
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() {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.email, email));
for (const u of existing) {
await testDb.db.delete(schema.feedback).where(eq(schema.feedback.userId, u.id));
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
it("skapar en öppen feedback-ticket", async () => {
const reg = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: {
email,
password: "super-säkert-lösen-123!",
displayName: "Feedback Tester",
},
});
expect(reg.statusCode).toBe(201);
const { accessToken } = JSON.parse(reg.body) as { accessToken: string };
const res = await app.inject({
method: "POST",
url: "/api/feedback",
headers: { authorization: `Bearer ${accessToken}` },
payload: {
typ: "bug",
rubrik: "Knappen fungerar inte",
text: "När jag trycker på spara händer ingenting.",
plattform: "ios",
appVersion: "1.2.3",
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body) as { id: string; status: string };
expect(body.status).toBe("oppen");
const row = await testDb.db
.select({ rubrik: schema.feedback.rubrik, status: schema.feedback.status })
.from(schema.feedback)
.where(eq(schema.feedback.id, body.id))
.limit(1);
expect(row[0]?.rubrik).toBe("Knappen fungerar inte");
});
});
+3
View File
@@ -240,6 +240,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
creatorUserId: userId,
creatorDisplayName: "Test",
status: "published",
verificationStatus: "editorial",
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
dna: {
cuisine: "swedish",
@@ -337,6 +338,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
user_preferences: ["user_id"],
user_health_profiles: ["user_id"],
user_locale_preferences: ["user_id"],
feedback: ["user_id"],
idempotency_keys: ["user_id"],
push_tokens: ["user_id"],
notifications: ["user_id"],
@@ -422,6 +424,7 @@ describe("DELETE /v1/me — GDPR residual completeness", () => {
["inventory_transactions", "actor_user_id"],
["meal_boxes", "reserved_for_user_id"],
["shopping_list_items", "added_by_user_id"],
["feedback", "user_id"],
];
for (const [table, column] of checks) {
+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);