diff --git a/apps/api/src/routes/feedback.ts b/apps/api/src/routes/feedback.ts new file mode 100644 index 0000000..a71f146 --- /dev/null +++ b/apps/api/src/routes/feedback.ts @@ -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 }; + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 0144eab..1f27be4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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; } diff --git a/apps/api/test/feedback.test.ts b/apps/api/test/feedback.test.ts new file mode 100644 index 0000000..8cba925 --- /dev/null +++ b/apps/api/test/feedback.test.ts @@ -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>; + 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"); + }); +}); diff --git a/apps/api/test/me.residual.test.ts b/apps/api/test/me.residual.test.ts index 1ca21b7..756e078 100644 --- a/apps/api/test/me.residual.test.ts +++ b/apps/api/test/me.residual.test.ts @@ -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) { diff --git a/apps/api/test/ops.test.ts b/apps/api/test/ops.test.ts index 5015cc2..7feaf72 100644 --- a/apps/api/test/ops.test.ts +++ b/apps/api/test/ops.test.ts @@ -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>; @@ -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 }; 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`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`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); diff --git a/apps/worker/src/processors/safety-canary.ts b/apps/worker/src/processors/safety-canary.ts index 7c43114..9339db1 100644 --- a/apps/worker/src/processors/safety-canary.ts +++ b/apps/worker/src/processors/safety-canary.ts @@ -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>; } const MICROCENTS_PER_USD = 100_000_000; @@ -76,7 +85,8 @@ async function aiScanBlock( AND properties->>'latencyMs' IS NOT NULL `); const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as - { p50: string | number | null; p95: string | number | null } | undefined; + | { p50: string | number | null; p95: string | number | null } + | undefined; const latestErrors = await db.execute(sql` SELECT properties->>'errorCode' AS code, occurred_at AS tid @@ -86,9 +96,7 @@ async function aiScanBlock( ORDER BY occurred_at DESC LIMIT 5 `); - const latestErrorsRows = ( - Array.isArray(latestErrors) ? latestErrors : latestErrors.rows - ) as Array<{ + const latestErrorsRows = (Array.isArray(latestErrors) ? latestErrors : latestErrors.rows) as Array<{ code: string | null; tid: string | Date; }>; @@ -98,10 +106,8 @@ async function aiScanBlock( FROM ai_usage_counters WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM') `); - const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as - { total: bigint | number } | undefined; - const monthlyCostMicrocents = - typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total); + const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as { total: bigint | number } | undefined; + const monthlyCostMicrocents = typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total); const exact24h = await db.execute(sql` SELECT COALESCE(sum(cost_usd), 0)::float AS total @@ -110,8 +116,7 @@ async function aiScanBlock( AND updated_at >= now() - interval '24 hours' AND status IN ('awaiting_confirmation', 'completed') `); - const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as - { total: number } | undefined; + const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as { total: number } | undefined; const exactCostUsd = asNumber(exact24hRow?.total); let cost24hMicrocents: number | null = null; @@ -178,7 +183,8 @@ async function conversionWithin( (SELECT count(*) FROM converted) AS converted `); const row = (Array.isArray(result) ? result[0] : result.rows[0]) as - { total: number; converted: number } | undefined; + | { total: number; converted: number } + | undefined; if (!row || asNumber(row.total) === 0) return null; return asNumber(row.converted) / asNumber(row.total); } @@ -210,7 +216,8 @@ async function householdConversionWithin( (SELECT count(*) FROM converted) AS converted `); const row = (Array.isArray(result) ? result[0] : result.rows[0]) as - { total: number; converted: number } | undefined; + | { total: number; converted: number } + | undefined; if (!row || asNumber(row.total) === 0) return null; return asNumber(row.converted) / asNumber(row.total); } @@ -236,7 +243,8 @@ async function cohortRetention(db: Database, day: number): Promise { FROM product_analytics_events `); const row = (Array.isArray(result) ? result[0] : result.rows[0]) as - { c24: number; c7: number; opened: number; viewed: number } | undefined; + | { c24: number; c7: number; opened: number; viewed: number } + | undefined; const tips = await db.execute(sql` SELECT count(*)::int AS n @@ -298,7 +307,8 @@ async function paymentBlock(db: Database): Promise { FROM subscriptions `); const row = (Array.isArray(result) ? result[0] : result.rows[0]) as - { failed: number; grace: number } | undefined; + | { failed: number; grace: number } + | undefined; const trials = await db.execute(sql` SELECT count(*)::int AS n @@ -306,8 +316,7 @@ async function paymentBlock(db: Database): Promise { WHERE ends_at >= now() AND ends_at <= now() + interval '48 hours' `); - const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as - { n: number } | undefined; + const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as { n: number } | undefined; const store = await db.execute(sql` SELECT @@ -317,7 +326,8 @@ async function paymentBlock(db: Database): Promise { WHERE created_at >= now() - interval '7 days' `); const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as - { refunds: number; chargebacks: number } | undefined; + | { refunds: number; chargebacks: number } + | undefined; return { failed_nu: asNumber(row?.failed), @@ -361,17 +371,231 @@ async function safetyBlock(db: Database): Promise { }; } -export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise { - const { db, budgetUsd, dailySpendUsd, queueSummary } = options; - const [ai, activation, engagement, payment, safety] = await Promise.all([ - aiScanBlock(db, budgetUsd, dailySpendUsd), - activationBlock(db), - engagementBlock(db), - paymentBlock(db), - safetyBlock(db), - ]); +function appBlock(): OpsAppBlock { + return { app: "cibello", generated_at: new Date().toISOString() }; +} + +function getPlanPrices(): Record { + const defaults = Object.fromEntries(SUBSCRIPTION_PLANS.map((p) => [p, null])) as Record< + SubscriptionPlan, + number | null + >; + const envJson = process.env.SUBSCRIPTION_PLAN_PRICES_JSON; + if (envJson) { + try { + const parsed = JSON.parse(envJson) as Record; + for (const plan of SUBSCRIPTION_PLANS) { + if (parsed[plan] !== undefined) defaults[plan] = parsed[plan]; + } + } catch { + // Ignorera trasig env-JSON; använd null för alla planer. + } + } + return defaults; +} + +async function economyBlock( + db: Database, + planPrices: Record, +): Promise { + const active = await db.execute(sql` + SELECT plan, count(*)::int AS n + FROM subscriptions + WHERE status = 'active' + AND (expires_at IS NULL OR expires_at > now()) + GROUP BY plan + `); + const activeRows = (Array.isArray(active) ? active : active.rows) as Array<{ + plan: SubscriptionPlan; + n: number; + }>; + let mrr: number | null = 0; + for (const row of activeRows) { + const price = planPrices[row.plan]; + if (price === null) { + mrr = null; + break; + } + mrr = (mrr ?? 0) + price * row.n; + } + + async function revenueFor(hours: number): Promise { + const events = await db.execute(sql` + SELECT properties + FROM product_analytics_events + WHERE event_name = 'subscription_started' + AND occurred_at >= now() - make_interval(hours => ${hours}) + `); + const rows = (Array.isArray(events) ? events : events.rows) as Array<{ + properties: { priceOer?: number; plan?: SubscriptionPlan } | null; + }>; + let total: number | null = 0; + for (const row of rows) { + const props = row.properties ?? {}; + const price = + typeof props.priceOer === "number" ? props.priceOer : planPrices[props.plan ?? "free"]; + if (price === null) { + total = null; + break; + } + total = (total ?? 0) + price; + } + return total; + } + + const [intakt24h, intakt7d] = await Promise.all([revenueFor(24), revenueFor(24 * 7)]); return { + mrr, + intakt_24h: intakt24h, + intakt_7d: intakt7d, + valuta: "SEK", + }; +} + +async function usersBlock(db: Database): Promise { + const result = await db.execute(sql` + SELECT + count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(mins => 5))::int AS aktiva_nu, + count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(hours => 24))::int AS dau, + count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(days => 30))::int AS mau, + count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(hours => 24))::int AS nya_24h, + count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(days => 7))::int AS nya_7d + FROM product_analytics_events + `); + const row = (Array.isArray(result) ? result[0] : result.rows[0]) as { + aktiva_nu: number; + dau: number; + mau: number; + nya_24h: number; + nya_7d: number; + }; + return { + aktiva_nu: asNumber(row.aktiva_nu), + dau: asNumber(row.dau), + mau: asNumber(row.mau), + nya_24h: asNumber(row.nya_24h), + nya_7d: asNumber(row.nya_7d), + }; +} + +async function subscriptionsBlock(db: Database): Promise { + const [trials, paying] = await Promise.all([ + db.execute(sql`SELECT count(*)::int AS n FROM trials WHERE ends_at >= now()`), + db.execute(sql` + SELECT count(*)::int AS n + FROM subscriptions + WHERE status = 'active' AND (expires_at IS NULL OR expires_at > now()) + `), + ]); + const trialAktiva = asNumber((Array.isArray(trials) ? trials[0] : trials.rows[0]).n); + const betalande = asNumber((Array.isArray(paying) ? paying[0] : paying.rows[0]).n); + + const trialConv24h = await db.execute(sql` + SELECT count(DISTINCT t.user_id)::int AS n + FROM product_analytics_events t + INNER JOIN product_analytics_events s ON s.user_id = t.user_id + WHERE t.event_name = 'trial_started' + AND s.event_name = 'subscription_started' + AND s.occurred_at >= now() - make_interval(hours => 24) + AND s.occurred_at >= t.occurred_at + `); + const trialConv7d = await db.execute(sql` + SELECT count(DISTINCT t.user_id)::int AS n + FROM product_analytics_events t + INNER JOIN product_analytics_events s ON s.user_id = t.user_id + WHERE t.event_name = 'trial_started' + AND s.event_name = 'subscription_started' + AND s.occurred_at >= now() - make_interval(days => 7) + AND s.occurred_at >= t.occurred_at + `); + + const konv30d = await conversionWithin(db, "trial_started", "subscription_started", 30, 30 * 24); + + const avslutade = await db.execute(sql` + SELECT count(*)::int AS n + FROM subscription_events + WHERE event_type IN ('expired', 'cancelled') + AND created_at >= now() - make_interval(hours => 24) + `); + + return { + trial_aktiva: trialAktiva, + trial_konverterade_24h: asNumber((Array.isArray(trialConv24h) ? trialConv24h[0] : trialConv24h.rows[0]).n), + trial_konverterade_7d: asNumber((Array.isArray(trialConv7d) ? trialConv7d[0] : trialConv7d.rows[0]).n), + konverteringsgrad_30d: roundRate(konv30d), + betalande, + avslutade_24h: asNumber((Array.isArray(avslutade) ? avslutade[0] : avslutade.rows[0]).n), + }; +} + +function storeBlock(): OpsStoreBlock { + return { butik: null, all_fields_phase_2: null }; +} + +async function feedbackBlock(db: Database): Promise { + const counts = await db.execute(sql` + SELECT + count(*) FILTER (WHERE status = 'oppen')::int AS oppna, + count(*) FILTER (WHERE created_at >= now() - make_interval(hours => 24))::int AS nya_24h + FROM feedback + `); + const countsRow = (Array.isArray(counts) ? counts[0] : counts.rows[0]) as { + oppna: number; + nya_24h: number; + }; + + const latest = await db + .select({ + rubrik: schema.feedback.rubrik, + typ: schema.feedback.typ, + status: schema.feedback.status, + createdAt: schema.feedback.createdAt, + }) + .from(schema.feedback) + .orderBy(sql`${schema.feedback.createdAt} DESC`) + .limit(5); + + return { + oppna: asNumber(countsRow.oppna), + nya_24h: asNumber(countsRow.nya_24h), + senaste: + latest.length > 0 + ? latest.map((f) => ({ + rubrik: f.rubrik, + typ: f.typ, + status: f.status, + created_at: f.createdAt.toISOString(), + })) + : null, + }; +} + +export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise { + const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options; + const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) }; + const [app, economy, users, subscriptions, butik, feedbackData, ai, activation, engagement, payment, safety] = + await Promise.all([ + Promise.resolve(appBlock()), + economyBlock(db, planPrices), + usersBlock(db), + subscriptionsBlock(db), + Promise.resolve(storeBlock()), + feedbackBlock(db), + aiScanBlock(db, budgetUsd, dailySpendUsd), + activationBlock(db), + engagementBlock(db), + paymentBlock(db), + safetyBlock(db), + ]); + + return { + app, + ekonomi: economy, + anvandare: users, + prenumerationer: subscriptions, + butik, + feedback: feedbackData, ai_scan: ai, aktivering: activation, engagemang: engagement, diff --git a/packages/database/src/schema/feedback.ts b/packages/database/src/schema/feedback.ts new file mode 100644 index 0000000..e7955b2 --- /dev/null +++ b/packages/database/src/schema/feedback.ts @@ -0,0 +1,27 @@ +import { index, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; +import { createdAt } from "./_shared.js"; +import { users } from "./users.js"; + +export const feedbackTypeEnum = pgEnum("feedback_type", ["bug", "onskemal"]); +export const feedbackStatusEnum = pgEnum("feedback_status", ["oppen", "pagar", "stangd"]); + +/** Användarfeedback: buggrapporter och önskemål (doc §2). */ +export const feedback = pgTable( + "feedback", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }), + typ: feedbackTypeEnum("typ").notNull(), + rubrik: varchar("rubrik", { length: 200 }).notNull(), + /** Full text – visas aldrig i ops-sammanfattningen. */ + text: text("text").notNull(), + status: feedbackStatusEnum("status").notNull().default("oppen"), + plattform: varchar("plattform", { length: 16 }), + appVersion: varchar("app_version", { length: 32 }), + createdAt: createdAt(), + }, + (t) => [ + index("feedback_status_created_idx").on(t.status, t.createdAt), + index("feedback_user_idx").on(t.userId, t.createdAt), + ], +); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index ad6c53c..bd8ada5 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -18,6 +18,7 @@ export * from "./memory.js"; export * from "./seasons.js"; export * from "./subscriptions.js"; export * from "./analytics.js"; +export * from "./feedback.js"; export * from "./ops.js"; export * from "./platform.js"; export * from "./releaseGates.js"; diff --git a/packages/database/src/seed/data/recipes.ts b/packages/database/src/seed/data/recipes.ts index d2beba5..b22b81b 100644 --- a/packages/database/src/seed/data/recipes.ts +++ b/packages/database/src/seed/data/recipes.ts @@ -182,7 +182,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ text: "Sänk värmen, tillsätt vitlöken och stek 30 sekunder utan att den tar färg.", timerSeconds: 30, }, - { text: "Häll i grädden, låt sjuda ihop 3–4 minuter och rör ner osten.", timerSeconds: 210 }, + { text: "Häll i grädden, låt sjuda ihop 3–4 minuter och rör ner osten. Se till att korven är genomstekt.", timerSeconds: 210 }, { text: "Vänd ner spenaten tills den precis sjunker ihop. Smaka av med salt, peppar och ev. chiliflakes.", }, @@ -318,7 +318,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ ], steps: [ { text: "Bryn färsen i olja på hög värme tills den fått färg." }, - { text: "Rör i tacokryddan och 1 dl vatten, låt puttra 5 minuter.", timerSeconds: 300 }, + { text: "Rör i tacokryddan och 1 dl vatten, låt puttra 5 minuter tills färsen är genomstekt.", timerSeconds: 300 }, { text: "Tärna tomat, strimla sallad och riv osten. Ställ fram allt i skålar." }, { text: "Värm tortillabröden enligt paketet." }, { text: "Låt alla bygga sina egna tacos vid bordet." }, @@ -413,7 +413,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ text: "Lägg laxen i en smord form, salta, peppra och toppa med citronskivor och halva dillen.", }, { - text: "Baka i ugnen 15–18 minuter tills laxen precis går att dela i mitten.", + text: "Baka i ugnen 15–18 minuter tills laxen är genomstekt och går att dela i mitten.", timerSeconds: 960, temperatureC: 200, tip: "Innertemperatur 52–55 °C ger saftig lax.", @@ -506,7 +506,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ { text: "Koka riset enligt paketets anvisning.", timerSeconds: 720 }, { text: "Stek korvstrimlor och lök i olja tills de fått lite färg." }, { text: "Rör i tomatpuré och paprikapulver, fräs 1 minut.", timerSeconds: 60 }, - { text: "Häll i grädden och senapen, låt sjuda 5 minuter.", timerSeconds: 300 }, + { text: "Häll i grädden och senapen, låt sjuda 5 minuter. Se till att korven är genomstekt.", timerSeconds: 300 }, { text: "Smaka av med svartpeppar och servera med riset." }, ], }, @@ -588,7 +588,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ steps: [ { text: "Koka nudlarna enligt paketet, skölj i kallt vatten och låt rinna av." }, { - text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg, 3–4 minuter.", + text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg och är genomstekt, 3–4 minuter.", timerSeconds: 210, }, { text: "Tillsätt grönsaker, vitlök och ingefära, woka 3 minuter till.", timerSeconds: 180 }, @@ -689,7 +689,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ }, { text: "Rör i tomatpuré och alla kryddor, fräs 1 minut.", timerSeconds: 60 }, { - text: "Tillsätt krossade tomater och 2 dl vatten. Sjud under lock 30 minuter.", + text: "Tillsätt krossade tomater och 2 dl vatten. Sjud under lock 30 minuter. Se till att färsen är genomstekt.", timerSeconds: 1800, }, { @@ -732,7 +732,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ steps: [ { text: "Koka riset. Ånga eller koka broccolin de sista 4 minuterna.", timerSeconds: 720 }, { - text: "Stek laxen i olja med skinnsidan ner 3–4 minuter, vänd och stek 2 minuter till.", + text: "Stek laxen i olja med skinnsidan ner 3–4 minuter, vänd och stek 2 minuter till tills laxen är genomstekt.", timerSeconds: 330, }, { @@ -783,7 +783,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ }, { text: "Rör i tomatpuré, riven morot och oregano." }, { - text: "Tillsätt krossade tomater och 1 dl vatten. Låt sjuda minst 20 minuter.", + text: "Tillsätt krossade tomater och 1 dl vatten. Låt sjuda minst 20 minuter. Se till att färsen är genomstekt.", timerSeconds: 1200, tip: "Längre puttertid = rundare smak.", }, @@ -831,7 +831,7 @@ export const SEED_RECIPES: SeedRecipe[] = [ { text: "Stek potatistärningarna i hälften av smöret på hög värme tills gyllene och krispiga.", }, - { text: "Tillsätt lök och korv och stek tills allt fått fin färg." }, + { text: "Tillsätt lök och korv och stek tills allt fått fin färg. Se till att korven är genomstekt." }, { text: "Stek äggen i resten av smöret." }, { text: "Salta, peppra och servera pytten med stekt ägg och gärna rödbetor." }, ], diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 5ffc319..f532640 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -298,6 +298,7 @@ export const RECIPE_VERIFICATION_STATUSES = [ "community", "verified", "editorial", + "rejected", ] as const; export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number]; diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index aa006ab..b06b335 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -1,3 +1,50 @@ +export interface OpsAppBlock { + app: string; + generated_at: string; +} + +export interface OpsEconomyBlock { + mrr: number | null; + intakt_24h: number | null; + intakt_7d: number | null; + valuta: "SEK"; +} + +export interface OpsUsersBlock { + aktiva_nu: number; + dau: number; + mau: number; + nya_24h: number; + nya_7d: number; +} + +export interface OpsSubscriptionsBlock { + trial_aktiva: number; + trial_konverterade_24h: number; + trial_konverterade_7d: number; + konverteringsgrad_30d: number | null; + betalande: number; + avslutade_24h: number; +} + +export interface OpsStoreBlock { + butik: null; + all_fields_phase_2: null; +} + +export interface OpsFeedbackItem { + rubrik: string; + typ: "bug" | "onskemal"; + status: "oppen" | "pagar" | "stangd"; + created_at: string; +} + +export interface OpsFeedbackBlock { + oppna: number; + nya_24h: number; + senaste: OpsFeedbackItem[] | null; +} + export interface OpsAiScanBlock { scans_24h: number; scans_7d: number; @@ -54,6 +101,12 @@ export interface OpsSafetyBlock { } export interface OpsSummary { + app: OpsAppBlock; + ekonomi: OpsEconomyBlock; + anvandare: OpsUsersBlock; + prenumerationer: OpsSubscriptionsBlock; + butik: OpsStoreBlock; + feedback: OpsFeedbackBlock; ai_scan: OpsAiScanBlock; aktivering: OpsActivationBlock; engagemang: OpsEngagementBlock;