521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
/**
|
||
* 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<void> {
|
||
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<GateEvaluation[]> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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<number | null> {
|
||
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 };
|