Fas 1a punkt 2: release_gates tabell + go/no-go-vy
This commit is contained in:
@@ -2,3 +2,4 @@ export * from "./client.js";
|
||||
export * as schema from "./schema/index.js";
|
||||
export * from "./schema/index.js";
|
||||
export * from "./analytics-gdpr.js";
|
||||
export * from "./release-gates.js";
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -17,3 +17,4 @@ export * from "./seasons.js";
|
||||
export * from "./subscriptions.js";
|
||||
export * from "./analytics.js";
|
||||
export * from "./platform.js";
|
||||
export * from "./releaseGates.js";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Release gates schema (spec §17).
|
||||
* Configurable go/no-go criteria evaluated against analytics events.
|
||||
*/
|
||||
import { boolean, doublePrecision, index, integer, pgEnum, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import { createdAt, updatedAt } from "./_shared.js";
|
||||
import {
|
||||
RELEASE_GATE_CATEGORIES,
|
||||
RELEASE_GATE_COMPARISONS,
|
||||
RELEASE_GATE_STATUSES,
|
||||
tuple,
|
||||
} from "@app/shared-types";
|
||||
|
||||
export const releaseGateCategoryEnum = pgEnum(
|
||||
"release_gate_category",
|
||||
tuple(RELEASE_GATE_CATEGORIES),
|
||||
);
|
||||
export const releaseGateComparisonEnum = pgEnum(
|
||||
"release_gate_comparison",
|
||||
tuple(RELEASE_GATE_COMPARISONS),
|
||||
);
|
||||
export const releaseGateStatusEnum = pgEnum(
|
||||
"release_gate_status",
|
||||
tuple(RELEASE_GATE_STATUSES),
|
||||
);
|
||||
|
||||
export const releaseGates = pgTable(
|
||||
"release_gates",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
gateKey: varchar("gate_key", { length: 64 }).notNull().unique(),
|
||||
category: releaseGateCategoryEnum("category").notNull(),
|
||||
nameSv: text("name_sv").notNull(),
|
||||
nameEn: text("name_en").notNull(),
|
||||
descriptionSv: text("description_sv"),
|
||||
descriptionEn: text("description_en"),
|
||||
/** Target threshold (0–1 for ratios, absolute count for counts). */
|
||||
targetValue: doublePrecision("target_value").notNull(),
|
||||
comparison: releaseGateComparisonEnum("comparison").notNull(),
|
||||
/** How many days of analytics events to include in the evaluation. */
|
||||
evaluationWindowDays: integer("evaluation_window_days").notNull().default(7),
|
||||
/** Latest computed value, if any. */
|
||||
lastValue: doublePrecision("last_value"),
|
||||
lastEvaluatedAt: timestamp("last_evaluated_at", { withTimezone: true }),
|
||||
status: releaseGateStatusEnum("status").notNull().default("pending"),
|
||||
/** True if this gate blocks a broad launch until passed. */
|
||||
blocking: boolean("blocking").notNull().default(false),
|
||||
/** Free-form admin notes (e.g., why a gate was waived). */
|
||||
notes: text("notes"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("release_gates_category_idx").on(t.category),
|
||||
index("release_gates_status_idx").on(t.status),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it, beforeEach, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createDatabase, closeDatabase } from "../src/client.js";
|
||||
import { schema, seedReleaseGates, evaluateReleaseGates, summarizeReleaseGates } from "../src/index.js";
|
||||
import { BUILT_IN_RELEASE_GATES } from "@app/shared-types";
|
||||
|
||||
const { db, pool } = createDatabase();
|
||||
|
||||
async function reset() {
|
||||
await db.delete(schema.productAnalyticsEvents);
|
||||
await db.delete(schema.releaseGates);
|
||||
await db
|
||||
.delete(schema.users)
|
||||
.where(eq(schema.users.email, "release-gates-test@example.invalid"));
|
||||
}
|
||||
|
||||
async function createTestUser(id: string) {
|
||||
const [user] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
id,
|
||||
email: `release-gates-test-${id.slice(0, 8)}@example.invalid`,
|
||||
displayName: "Release Gate Test",
|
||||
locale: "sv-SE",
|
||||
})
|
||||
.returning({ id: schema.users.id });
|
||||
return user!.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await reset();
|
||||
await closeDatabase();
|
||||
});
|
||||
|
||||
describe("release gates", () => {
|
||||
it("seeds built-in gates idempotently", async () => {
|
||||
await seedReleaseGates(db);
|
||||
const first = await db.select().from(schema.releaseGates);
|
||||
expect(first.length).toBe(BUILT_IN_RELEASE_GATES.length);
|
||||
|
||||
await seedReleaseGates(db);
|
||||
const second = await db.select().from(schema.releaseGates);
|
||||
expect(second.length).toBe(BUILT_IN_RELEASE_GATES.length);
|
||||
});
|
||||
|
||||
it("evaluates first_scan_rate from product_analytics_events", async () => {
|
||||
await seedReleaseGates(db);
|
||||
const userA = await createTestUser("11111111-1111-1111-1111-111111111111");
|
||||
const userB = await createTestUser("22222222-2222-2222-2222-222222222222");
|
||||
const now = new Date();
|
||||
await db.insert(schema.productAnalyticsEvents).values([
|
||||
{ eventName: "account_created", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
|
||||
{ eventName: "account_created", userId: userB, occurredAt: now, receivedAt: now, properties: {} },
|
||||
{ eventName: "scan_completed", userId: userA, occurredAt: now, receivedAt: now, properties: {} },
|
||||
]);
|
||||
|
||||
const results = await evaluateReleaseGates(db);
|
||||
const firstScan = results.find((g) => g.gateKey === "first_scan_rate");
|
||||
expect(firstScan).toBeDefined();
|
||||
expect(firstScan?.value).toBeCloseTo(0.5);
|
||||
expect(firstScan?.status).toBe("failed"); // target 70%
|
||||
});
|
||||
|
||||
it("summarizes overall go/no-go verdict", async () => {
|
||||
await seedReleaseGates(db);
|
||||
const results = await evaluateReleaseGates(db);
|
||||
const summary = summarizeReleaseGates(results);
|
||||
expect(summary.overall).toBeOneOf(["go", "no_go", "pending"]);
|
||||
expect(summary.passed + summary.failed + summary.pending + summary.notMeasurable).toBe(
|
||||
BUILT_IN_RELEASE_GATES.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists evaluation results", async () => {
|
||||
await seedReleaseGates(db);
|
||||
const user = await createTestUser("33333333-3333-3333-3333-333333333333");
|
||||
const now = new Date();
|
||||
await db.insert(schema.productAnalyticsEvents).values([
|
||||
{ eventName: "account_created", userId: user, occurredAt: now, receivedAt: now, properties: {} },
|
||||
{ eventName: "scan_completed", userId: user, occurredAt: now, receivedAt: now, properties: {} },
|
||||
]);
|
||||
|
||||
await evaluateReleaseGates(db);
|
||||
const [gate] = await db.select().from(schema.releaseGates).where(eq(schema.releaseGates.gateKey, "first_scan_rate"));
|
||||
expect(gate?.lastValue).toBeCloseTo(1);
|
||||
expect(gate?.status).toBe("passed");
|
||||
expect(gate?.lastEvaluatedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -7,3 +7,4 @@ export * from "./locale.js";
|
||||
export * from "./money.js";
|
||||
export * from "./measurement.js";
|
||||
export * from "./analytics.js";
|
||||
export * from "./release-gates.js";
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Release gate definitions and go/no-go taxonomy (spec §17).
|
||||
*/
|
||||
|
||||
export const RELEASE_GATE_CATEGORIES = [
|
||||
"product",
|
||||
"quality",
|
||||
"retention",
|
||||
"economy",
|
||||
] as const;
|
||||
|
||||
export type ReleaseGateCategory = (typeof RELEASE_GATE_CATEGORIES)[number];
|
||||
|
||||
export const RELEASE_GATE_COMPARISONS = ["gte", "lte", "eq"] as const;
|
||||
|
||||
export type ReleaseGateComparison = (typeof RELEASE_GATE_COMPARISONS)[number];
|
||||
|
||||
export const RELEASE_GATE_STATUSES = [
|
||||
"pending",
|
||||
"passed",
|
||||
"failed",
|
||||
"blocked",
|
||||
"not_measurable",
|
||||
] as const;
|
||||
|
||||
export type ReleaseGateStatus = (typeof RELEASE_GATE_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Built-in release gates seeded from §17 go/no-go criteria.
|
||||
* Values are starting targets; admins can tune them before launch.
|
||||
*/
|
||||
export const BUILT_IN_RELEASE_GATES: Array<{
|
||||
gateKey: string;
|
||||
category: ReleaseGateCategory;
|
||||
nameSv: string;
|
||||
nameEn: string;
|
||||
descriptionSv: string;
|
||||
descriptionEn: string;
|
||||
targetValue: number;
|
||||
comparison: ReleaseGateComparison;
|
||||
evaluationWindowDays: number;
|
||||
blocking: boolean;
|
||||
}> = [
|
||||
// Product
|
||||
{
|
||||
gateKey: "first_scan_rate",
|
||||
category: "product",
|
||||
nameSv: "Första skanning genomförd",
|
||||
nameEn: "First scan completed",
|
||||
descriptionSv: "Andel användare som genomför minst en skanning efter kontoregistrering.",
|
||||
descriptionEn: "Share of users who complete at least one scan after account creation.",
|
||||
targetValue: 0.7,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "recipe_view_rate",
|
||||
category: "product",
|
||||
nameSv: "Rekommendation visad",
|
||||
nameEn: "Recommendation viewed",
|
||||
descriptionSv: "Andel användare som ser minst ett relevant recept.",
|
||||
descriptionEn: "Share of users who view at least one relevant recipe.",
|
||||
targetValue: 0.5,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "recipe_save_or_cook_day0",
|
||||
category: "product",
|
||||
nameSv: "Recept sparat eller tillagat dag 0",
|
||||
nameEn: "Recipe saved or cooked on day 0",
|
||||
descriptionSv: "Andel användare som sparar eller startar ett recept samma dag som registrering.",
|
||||
descriptionEn: "Share of users who save or start cooking a recipe on registration day.",
|
||||
targetValue: 0.35,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 1,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "second_inventory_event_week1",
|
||||
category: "product",
|
||||
nameSv: "Andra lagerhändelsen inom 7 dagar",
|
||||
nameEn: "Second inventory event within 7 days",
|
||||
descriptionSv: "Andel användare som lägger till, konsumerar eller kasserar en vara inom en vecka.",
|
||||
descriptionEn: "Share of users who add, consume, or discard an item within one week.",
|
||||
targetValue: 0.25,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "household_collaboration_rate",
|
||||
category: "product",
|
||||
nameSv: "Hushållssamarbete",
|
||||
nameEn: "Household collaboration",
|
||||
descriptionSv: "Andel hushåll där en andra medlem bjuds in eller är aktiv.",
|
||||
descriptionEn: "Share of households where a second member is invited or active.",
|
||||
targetValue: 0.2,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 14,
|
||||
blocking: false,
|
||||
},
|
||||
{
|
||||
gateKey: "weekly_trusted_meal_week2",
|
||||
category: "product",
|
||||
nameSv: "Weekly Trusted Meal vecka 2",
|
||||
nameEn: "Weekly trusted meal week 2",
|
||||
descriptionSv: "Andel aktiverade hushåll som lagar minst en måltid under vecka två.",
|
||||
descriptionEn: "Share of activated households that cook at least one meal in week two.",
|
||||
targetValue: 0.3,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 14,
|
||||
blocking: false,
|
||||
},
|
||||
|
||||
// Quality
|
||||
{
|
||||
gateKey: "critical_allergy_errors",
|
||||
category: "quality",
|
||||
nameSv: "Kritiska allergifel",
|
||||
nameEn: "Critical allergy errors",
|
||||
descriptionSv: "Accepterade kritiska allergifel ska vara noll.",
|
||||
descriptionEn: "Accepted critical allergy errors must be zero.",
|
||||
targetValue: 0,
|
||||
comparison: "lte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "correction_rate",
|
||||
category: "quality",
|
||||
nameSv: "Korrigeringsgrad skanning",
|
||||
nameEn: "Scan correction rate",
|
||||
descriptionSv: "Andel skannade varor som korrigeras av användaren.",
|
||||
descriptionEn: "Share of scanned items that the user corrects.",
|
||||
targetValue: 0.15,
|
||||
comparison: "lte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: false,
|
||||
},
|
||||
{
|
||||
gateKey: "sync_conflict_resolution_rate",
|
||||
category: "quality",
|
||||
nameSv: "Sync-konflikter lösta",
|
||||
nameEn: "Sync conflicts resolved",
|
||||
descriptionSv: "Andel sync-konflikter som löses utan dataförlust.",
|
||||
descriptionEn: "Share of sync conflicts resolved without data loss.",
|
||||
targetValue: 0.95,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: false,
|
||||
},
|
||||
|
||||
// Retention
|
||||
{
|
||||
gateKey: "retention_d1",
|
||||
category: "retention",
|
||||
nameSv: "D1-retention",
|
||||
nameEn: "D1 retention",
|
||||
descriptionSv: "Andel användare aktiva dagen efter registrering.",
|
||||
descriptionEn: "Share of users active one day after registration.",
|
||||
targetValue: 0.4,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 7,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "retention_d7",
|
||||
category: "retention",
|
||||
nameSv: "D7-retention",
|
||||
nameEn: "D7 retention",
|
||||
descriptionSv: "Andel användare aktiva sju dagar efter registrering.",
|
||||
descriptionEn: "Share of users active seven days after registration.",
|
||||
targetValue: 0.2,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 14,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "retention_d30",
|
||||
category: "retention",
|
||||
nameSv: "D30-retention",
|
||||
nameEn: "D30 retention",
|
||||
descriptionSv: "Andel användare aktiva 30 dagar efter registrering.",
|
||||
descriptionEn: "Share of users active 30 days after registration.",
|
||||
targetValue: 0.1,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 60,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "retention_week4_household",
|
||||
category: "retention",
|
||||
nameSv: "Hushållsretention vecka 4",
|
||||
nameEn: "Week 4 household retention",
|
||||
descriptionSv: "Andel hushåll aktiva under vecka fyra.",
|
||||
descriptionEn: "Share of households active in week four.",
|
||||
targetValue: 0.15,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 35,
|
||||
blocking: false,
|
||||
},
|
||||
{
|
||||
gateKey: "paid_month2_retention",
|
||||
category: "retention",
|
||||
nameSv: "Betald månad 2-retention",
|
||||
nameEn: "Paid month 2 retention",
|
||||
descriptionSv: "Andel betalande hushåll kvar vid månad två.",
|
||||
descriptionEn: "Share of paying households retained at month two.",
|
||||
targetValue: 0.5,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 90,
|
||||
blocking: true,
|
||||
},
|
||||
|
||||
// Economy
|
||||
{
|
||||
gateKey: "trial_to_paid_rate",
|
||||
category: "economy",
|
||||
nameSv: "Trial till betalande",
|
||||
nameEn: "Trial to paid",
|
||||
descriptionSv: "Andel trial-användare som blir betalande.",
|
||||
descriptionEn: "Share of trial users who convert to paid.",
|
||||
targetValue: 0.25,
|
||||
comparison: "gte",
|
||||
evaluationWindowDays: 30,
|
||||
blocking: true,
|
||||
},
|
||||
{
|
||||
gateKey: "refund_rate",
|
||||
category: "economy",
|
||||
nameSv: "Återbetalningsgrad",
|
||||
nameEn: "Refund rate",
|
||||
descriptionSv: "Andel betalningar som återbetalas.",
|
||||
descriptionEn: "Share of payments refunded.",
|
||||
targetValue: 0.05,
|
||||
comparison: "lte",
|
||||
evaluationWindowDays: 30,
|
||||
blocking: false,
|
||||
},
|
||||
{
|
||||
gateKey: "monthly_churn",
|
||||
category: "economy",
|
||||
nameSv: "Månatlig churn",
|
||||
nameEn: "Monthly churn",
|
||||
descriptionSv: "Andel betalande hushåll som avslutar per månad.",
|
||||
descriptionEn: "Share of paying households that churn per month.",
|
||||
targetValue: 0.1,
|
||||
comparison: "lte",
|
||||
evaluationWindowDays: 60,
|
||||
blocking: false,
|
||||
},
|
||||
];
|
||||
@@ -13,3 +13,4 @@ export * from "./memory.js";
|
||||
export * from "./subscriptions.js";
|
||||
export * from "./locale.js";
|
||||
export * from "./analytics.js";
|
||||
export * from "./release-gates.js";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { RELEASE_GATE_CATEGORIES, RELEASE_GATE_COMPARISONS } from "@app/shared-types";
|
||||
|
||||
export const releaseGateUpdateSchema = z.object({
|
||||
targetValue: z.number().finite(),
|
||||
comparison: z.enum(RELEASE_GATE_COMPARISONS),
|
||||
evaluationWindowDays: z.number().int().min(1).max(365),
|
||||
blocking: z.boolean(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export type ReleaseGateUpdateInput = z.infer<typeof releaseGateUpdateSchema>;
|
||||
Reference in New Issue
Block a user