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
+6
View File
@@ -178,6 +178,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.returning({ id: schema.notifications.id });
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
const feedbackDel = await db
.delete(schema.feedback)
.where(eq(schema.feedback.userId, userId))
.returning({ id: schema.feedback.id });
if (feedbackDel.length) log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
// ---- 2. Recipes ----
const deletedRecipes = await db
.delete(schema.recipes)
+251 -27
View File
@@ -1,5 +1,6 @@
import { sql } from "drizzle-orm";
import type {
OpsAppBlock,
OpsActivationBlock,
OpsAiScanBlock,
OpsEngagementBlock,
@@ -7,7 +8,13 @@ import type {
OpsQueueBlock,
OpsSafetyBlock,
OpsSummary,
OpsEconomyBlock,
OpsUsersBlock,
OpsSubscriptionsBlock,
OpsStoreBlock,
OpsFeedbackBlock,
} from "@app/shared-types";
import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
import type { Database } from "./client.js";
import { schema } from "./index.js";
@@ -26,6 +33,8 @@ export interface ComputeOpsSummaryOptions {
/** Current daily spend in USD, from Redis budget store. */
dailySpendUsd?: number | null;
queueSummary?: OpsQueueSummary;
/** Optional override for subscription plan prices in SEK öre per month. */
planPrices?: Partial<Record<SubscriptionPlan, number | null>>;
}
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<number | null
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
{ total: number; active: number } | undefined;
| { total: number; active: number }
| undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.active) / asNumber(row.total);
}
@@ -271,7 +279,8 @@ async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
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<OpsPaymentBlock> {
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<OpsPaymentBlock> {
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<OpsPaymentBlock> {
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<OpsSafetyBlock> {
};
}
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
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<SubscriptionPlan, number | null> {
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<string, number | null>;
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<SubscriptionPlan, number | null>,
): Promise<OpsEconomyBlock> {
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<number | null> {
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<OpsUsersBlock> {
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<OpsSubscriptionsBlock> {
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<OpsFeedbackBlock> {
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<OpsSummary> {
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,
+27
View File
@@ -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),
],
);
+1
View File
@@ -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";
+9 -9
View File
@@ -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 34 minuter och rör ner osten.", timerSeconds: 210 },
{ text: "Häll i grädden, låt sjuda ihop 34 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 1518 minuter tills laxen precis går att dela i mitten.",
text: "Baka i ugnen 1518 minuter tills laxen är genomstekt och går att dela i mitten.",
timerSeconds: 960,
temperatureC: 200,
tip: "Innertemperatur 5255 °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, 34 minuter.",
text: "Hetta upp oljan i wok. Woka kycklingen tills den fått färg och är genomstekt, 34 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 34 minuter, vänd och stek 2 minuter till.",
text: "Stek laxen i olja med skinnsidan ner 34 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." },
],
+1
View File
@@ -298,6 +298,7 @@ export const RECIPE_VERIFICATION_STATUSES = [
"community",
"verified",
"editorial",
"rejected",
] as const;
export type RecipeVerificationStatus = (typeof RECIPE_VERIFICATION_STATUSES)[number];
+53
View File
@@ -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;