/** * Release gate evaluator (spec §17). * Computes go/no-go status from product_analytics_events. */ import { sql } from "drizzle-orm"; import type { Database } from "./client.js"; import { schema } from "./index.js"; import { BUILT_IN_RELEASE_GATES, RELEASE_GATE_STATUSES, type ReleaseGateComparison, type ReleaseGateStatus, } from "@app/shared-types"; export interface GateEvaluation { gateKey: string; category: string; nameSv: string; nameEn: string; targetValue: number; comparison: ReleaseGateComparison; value: number | null; status: ReleaseGateStatus; lastEvaluatedAt: string; blocking: boolean; notes?: string; } function compare(value: number, target: number, comparison: ReleaseGateComparison): boolean { if (comparison === "gte") return value >= target; if (comparison === "lte") return value <= target; return Math.abs(value - target) < Number.EPSILON; } function decideStatus( value: number | null, target: number, comparison: ReleaseGateComparison, measurable = true, ): ReleaseGateStatus { if (!measurable || value === null || Number.isNaN(value)) return "not_measurable"; return compare(value, target, comparison) ? "passed" : "failed"; } /** * Seed the built-in release gates from §17. Idempotent – existing rows are * left untouched so admin tuning is preserved. */ export async function seedReleaseGates(db: Database): Promise { for (const gate of BUILT_IN_RELEASE_GATES) { await db .insert(schema.releaseGates) .values({ gateKey: gate.gateKey, category: gate.category, nameSv: gate.nameSv, nameEn: gate.nameEn, descriptionSv: gate.descriptionSv, descriptionEn: gate.descriptionEn, targetValue: gate.targetValue, comparison: gate.comparison, evaluationWindowDays: gate.evaluationWindowDays, blocking: gate.blocking, status: "pending", }) .onConflictDoNothing({ target: schema.releaseGates.gateKey }); } } /** * Evaluate all release gates and persist results. */ export async function evaluateReleaseGates(db: Database): Promise { const gates = await db.select().from(schema.releaseGates).orderBy(schema.releaseGates.gateKey); const now = new Date(); const results: GateEvaluation[] = []; for (const gate of gates) { const value = await evaluateGate(db, gate.gateKey, gate.evaluationWindowDays); const status = decideStatus(value, gate.targetValue, gate.comparison, value !== null); const updated = await db .update(schema.releaseGates) .set({ lastValue: value, lastEvaluatedAt: now, status, updatedAt: now, }) .where(sql`${schema.releaseGates.id} = ${gate.id}`) .returning(); const row = updated[0] ?? gate; results.push({ gateKey: row.gateKey, category: row.category, nameSv: row.nameSv, nameEn: row.nameEn, targetValue: row.targetValue, comparison: row.comparison, value: row.lastValue, status: row.status, lastEvaluatedAt: row.lastEvaluatedAt?.toISOString() ?? now.toISOString(), blocking: row.blocking, notes: row.notes ?? undefined, }); } return results; } /** * Evaluate a single gate by key. Returns null if not measurable yet. */ export async function evaluateGate( db: Database, gateKey: string, windowDays: number, ): Promise { const end = new Date(); const start = new Date(); start.setUTCDate(start.getUTCDate() - windowDays); const startIso = start.toISOString(); const endIso = end.toISOString(); switch (gateKey) { case "first_scan_rate": return userConversion(db, "account_created", "scan_completed", startIso, endIso); case "recipe_view_rate": return userConversion(db, "account_created", "recommendations_viewed", startIso, endIso); case "recipe_save_or_cook_day0": return sameDayConversion(db, ["recipe_saved", "cooking_session_started"], startIso, endIso); case "second_inventory_event_week1": return repeatedEventRate( db, ["inventory_item_added", "inventory_item_consumed", "inventory_item_discarded"], 2, startIso, endIso, ); case "household_collaboration_rate": return householdCollaborationRate(db, startIso, endIso); case "weekly_trusted_meal_week2": return weeklyTrustedMealWeek2(db, startIso, endIso); case "critical_allergy_errors": // No dedicated event yet; report not_measurable until §5 allergy trust is built. return null; case "correction_rate": return correctionRate(db, startIso, endIso); case "sync_conflict_resolution_rate": return conflictResolutionRate(db, startIso, endIso); case "retention_d1": return cohortRetention(db, 1, startIso, endIso); case "retention_d7": return cohortRetention(db, 7, startIso, endIso); case "retention_d30": return cohortRetention(db, 30, startIso, endIso); case "retention_week4_household": return householdWeekRetention(db, 4, startIso, endIso); case "paid_month2_retention": // Requires subscription lifecycle data not yet collected in analytics events. return null; case "trial_to_paid_rate": return userConversion(db, "trial_started", "subscription_started", startIso, endIso); case "refund_rate": // Requires refund events from billing provider. return null; case "monthly_churn": // Requires subscription status history. return null; default: return null; } } // --------------------------------------------------------------------------- // Metric helpers // --------------------------------------------------------------------------- async function userConversion( db: Database, fromEvent: string, toEvent: string, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH base AS ( SELECT DISTINCT user_id FROM product_analytics_events WHERE event_name = ${fromEvent} AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND user_id IS NOT NULL ), converted AS ( SELECT DISTINCT b.user_id FROM base b INNER JOIN product_analytics_events e ON e.user_id = b.user_id WHERE e.event_name = ${toEvent} AND e.occurred_at >= ${startIso} AND e.occurred_at < ${endIso} ) SELECT (SELECT count(*) FROM base) AS total, (SELECT count(*) FROM converted) AS converted `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; converted: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.converted) / Number(row.total); } function inClause(values: string[]) { return sql`(${sql.join(values.map((v) => sql`${v}`), sql`, `)})`; } async function sameDayConversion( db: Database, toEvents: string[], startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH base AS ( SELECT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date FROM product_analytics_events WHERE event_name = 'account_created' AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND user_id IS NOT NULL ), converted AS ( SELECT DISTINCT b.user_id FROM base b INNER JOIN product_analytics_events e ON e.user_id = b.user_id WHERE e.event_name IN ${inClause(toEvents)} AND e.occurred_at >= ${startIso} AND e.occurred_at < ${endIso} AND (e.occurred_at AT TIME ZONE 'UTC')::date = b.cohort_date ) SELECT (SELECT count(DISTINCT user_id) FROM base) AS total, (SELECT count(*) FROM converted) AS converted `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; converted: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.converted) / Number(row.total); } async function repeatedEventRate( db: Database, events: string[], minOccurrences: number, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH base AS ( SELECT DISTINCT user_id FROM product_analytics_events WHERE event_name = 'account_created' AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND user_id IS NOT NULL ), event_counts AS ( SELECT b.user_id, count(*) AS n FROM base b INNER JOIN product_analytics_events e ON e.user_id = b.user_id WHERE e.event_name IN ${inClause(events)} AND e.occurred_at >= ${startIso} AND e.occurred_at < ${endIso} GROUP BY b.user_id HAVING count(*) >= ${minOccurrences} ) SELECT (SELECT count(*) FROM base) AS total, (SELECT count(*) FROM event_counts) AS converted `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; converted: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.converted) / Number(row.total); } async function householdCollaborationRate( db: Database, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH base AS ( SELECT DISTINCT household_id FROM product_analytics_events WHERE event_name = 'household_created' AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND household_id IS NOT NULL ), collaborative AS ( SELECT DISTINCT b.household_id FROM base b INNER JOIN product_analytics_events e ON e.household_id = b.household_id WHERE e.event_name IN ('household_invite_sent', 'household_invite_accepted', 'second_member_first_action') AND e.occurred_at >= ${startIso} AND e.occurred_at < ${endIso} ) SELECT (SELECT count(*) FROM base) AS total, (SELECT count(*) FROM collaborative) AS converted `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; converted: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.converted) / Number(row.total); } async function weeklyTrustedMealWeek2( db: Database, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH activated AS ( SELECT DISTINCT household_id, min(occurred_at) AS first_activated_at FROM product_analytics_events WHERE event_name = 'cooking_session_completed' AND household_id IS NOT NULL AND occurred_at >= ${startIso} AND occurred_at < ${endIso} GROUP BY household_id ), week2_cooks AS ( SELECT DISTINCT a.household_id FROM activated a INNER JOIN product_analytics_events e ON e.household_id = a.household_id AND e.event_name = 'cooking_session_completed' AND e.occurred_at >= a.first_activated_at + INTERVAL '7 days' AND e.occurred_at < a.first_activated_at + INTERVAL '14 days' ) SELECT (SELECT count(*) FROM activated) AS total, (SELECT count(*) FROM week2_cooks) AS converted `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; converted: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.converted) / Number(row.total); } async function correctionRate( db: Database, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` SELECT count(*) FILTER (WHERE event_name = 'scan_item_corrected') AS corrected, count(*) FILTER (WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected')) AS total FROM product_analytics_events WHERE event_name IN ('scan_item_confirmed', 'scan_item_corrected') AND occurred_at >= ${startIso} AND occurred_at < ${endIso} `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { corrected: number; total: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.corrected) / Number(row.total); } async function conflictResolutionRate( db: Database, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` SELECT count(*) FILTER (WHERE event_name = 'inventory_conflict_resolved') AS resolved, count(*) FILTER (WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved')) AS total FROM product_analytics_events WHERE event_name IN ('inventory_conflict_created', 'inventory_conflict_resolved') AND occurred_at >= ${startIso} AND occurred_at < ${endIso} `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { resolved: number; total: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.resolved) / Number(row.total); } async function cohortRetention( db: Database, day: number, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH cohort AS ( SELECT DISTINCT user_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date FROM product_analytics_events WHERE event_name = 'account_created' AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND user_id IS NOT NULL ), active AS ( SELECT DISTINCT c.user_id FROM cohort c INNER JOIN product_analytics_events e ON e.user_id = c.user_id WHERE (e.occurred_at AT TIME ZONE 'UTC')::date = c.cohort_date + ${day}::integer ) SELECT (SELECT count(DISTINCT user_id) FROM cohort) AS total, (SELECT count(*) FROM active) AS active `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; active: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.active) / Number(row.total); } async function householdWeekRetention( db: Database, week: number, startIso: string, endIso: string, ): Promise { const raw = await db.execute(sql` WITH cohort AS ( SELECT DISTINCT household_id, (occurred_at AT TIME ZONE 'UTC')::date AS cohort_date FROM product_analytics_events WHERE event_name = 'household_created' AND occurred_at >= ${startIso} AND occurred_at < ${endIso} AND household_id IS NOT NULL ), active AS ( SELECT DISTINCT c.household_id FROM cohort c INNER JOIN product_analytics_events e ON e.household_id = c.household_id WHERE (e.occurred_at AT TIME ZONE 'UTC')::date >= c.cohort_date + ${(week - 1) * 7}::integer AND (e.occurred_at AT TIME ZONE 'UTC')::date < c.cohort_date + ${week * 7}::integer ) SELECT (SELECT count(DISTINCT household_id) FROM cohort) AS total, (SELECT count(*) FROM active) AS active `); const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as { total: number; active: number; } | undefined; if (!row || Number(row.total) === 0) return null; return Number(row.active) / Number(row.total); } /** * Summarise the current go/no-go verdict across all gates. */ export function summarizeReleaseGates(gates: GateEvaluation[]): { overall: "go" | "no_go" | "pending"; passed: number; failed: number; blocked: number; pending: number; notMeasurable: number; } { let failed = 0; let blocked = 0; let pending = 0; let notMeasurable = 0; let passed = 0; for (const g of gates) { if (g.status === "passed") passed++; else if (g.status === "failed") { failed++; if (g.blocking) blocked++; } else if (g.status === "not_measurable") notMeasurable++; else pending++; } const overall = blocked > 0 ? "no_go" : failed > 0 ? "no_go" : pending > 0 ? "pending" : "go"; return { overall, passed, failed, blocked, pending, notMeasurable }; } export { RELEASE_GATE_STATUSES };