Files
Cibello-app/packages/database/src/release-gates.ts
T
2026-08-05 23:10:09 +07:00

521 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };