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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,16 +11,13 @@ const RAW_PROTEIN_REQUIRING_SAFE_COOKING = new Set([
|
||||
"minced_mixed",
|
||||
"meatball_pork_beef",
|
||||
"falukorv",
|
||||
"egg",
|
||||
"cod",
|
||||
"salmon",
|
||||
"shrimp",
|
||||
"anchovy_swedish",
|
||||
"pickled_herring",
|
||||
]);
|
||||
|
||||
const SAFE_COOKING_KEYWORDS_SV = [
|
||||
/\bgenomstekt\b/i,
|
||||
/\bgenomstek/i,
|
||||
/\bgenomkokt\b/i,
|
||||
/\bgenomgrillad\b/i,
|
||||
/\bgenomv\w+\b/i,
|
||||
@@ -170,7 +167,7 @@ export async function processSafetyCanary(ctx: WorkerContext): Promise<SafetyCan
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recipes.status, "published"),
|
||||
ne(schema.recipes.verificationStatus, "verified"),
|
||||
sql`${schema.recipes.verificationStatus} IN ('unverified', 'rejected')`,
|
||||
),
|
||||
);
|
||||
const overifieradeVisade = publicUnverified[0]?.count ?? 0;
|
||||
|
||||
Reference in New Issue
Block a user