@@ -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 } ;