|
|
|
@@ -0,0 +1,383 @@
|
|
|
|
|
import { sql } from "drizzle-orm";
|
|
|
|
|
import type {
|
|
|
|
|
OpsActivationBlock,
|
|
|
|
|
OpsAiScanBlock,
|
|
|
|
|
OpsEngagementBlock,
|
|
|
|
|
OpsPaymentBlock,
|
|
|
|
|
OpsQueueBlock,
|
|
|
|
|
OpsSafetyBlock,
|
|
|
|
|
OpsSummary,
|
|
|
|
|
} from "@app/shared-types";
|
|
|
|
|
import type { Database } from "./client.js";
|
|
|
|
|
import { schema } from "./index.js";
|
|
|
|
|
|
|
|
|
|
export interface OpsQueueSummary {
|
|
|
|
|
vantande: number;
|
|
|
|
|
aktiva: number;
|
|
|
|
|
misslyckade_24h: number;
|
|
|
|
|
aldsta_vantande_sek: number | null;
|
|
|
|
|
workers_ok: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ComputeOpsSummaryOptions {
|
|
|
|
|
db: Database;
|
|
|
|
|
/** Gemini daily budget in USD (0 = disabled). */
|
|
|
|
|
budgetUsd: number;
|
|
|
|
|
/** Current daily spend in USD, from Redis budget store. */
|
|
|
|
|
dailySpendUsd?: number | null;
|
|
|
|
|
queueSummary?: OpsQueueSummary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MICROCENTS_PER_USD = 100_000_000;
|
|
|
|
|
const ESTIMATED_SCAN_COST_USD = 0.0015;
|
|
|
|
|
const ESTIMATED_SCAN_COST_MICROCENTS = Math.round(ESTIMATED_SCAN_COST_USD * MICROCENTS_PER_USD);
|
|
|
|
|
|
|
|
|
|
function asNumber(value: unknown): number {
|
|
|
|
|
return Number(value ?? 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function safeDiv(numerator: number, denominator: number): number | null {
|
|
|
|
|
if (denominator === 0) return null;
|
|
|
|
|
return numerator / denominator;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function roundRate(value: number | null): number | null {
|
|
|
|
|
if (value === null || Number.isNaN(value)) return null;
|
|
|
|
|
return Math.round(value * 1_000_000) / 1_000_000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function countEvents(db: Database, name: string, hours: number): Promise<number> {
|
|
|
|
|
const result = await db.execute(sql`
|
|
|
|
|
SELECT count(*)::int AS n
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
WHERE event_name = ${name}
|
|
|
|
|
AND occurred_at >= now() - make_interval(hours => ${hours})`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as { n: number } | undefined;
|
|
|
|
|
return asNumber(row?.n);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function aiScanBlock(
|
|
|
|
|
db: Database,
|
|
|
|
|
budgetUsd: number,
|
|
|
|
|
dailySpendUsd: number | null | undefined,
|
|
|
|
|
): Promise<OpsAiScanBlock> {
|
|
|
|
|
const scans24h = await countEvents(db, "scan_completed", 24);
|
|
|
|
|
const scans7d = await countEvents(db, "scan_completed", 24 * 7);
|
|
|
|
|
const started24h = await countEvents(db, "scan_started", 24);
|
|
|
|
|
const failed24h = await countEvents(db, "scan_failed", 24);
|
|
|
|
|
|
|
|
|
|
const latency = await db.execute(sql`
|
|
|
|
|
SELECT
|
|
|
|
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY (properties->>'latencyMs')::int) AS p50,
|
|
|
|
|
percentile_cont(0.95) WITHIN GROUP (ORDER BY (properties->>'latencyMs')::int) AS p95
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
WHERE event_name = 'scan_completed'
|
|
|
|
|
AND occurred_at >= now() - interval '24 hours'
|
|
|
|
|
AND properties->>'latencyMs' IS NOT NULL
|
|
|
|
|
`);
|
|
|
|
|
const latencyRow = (Array.isArray(latency) ? latency[0] : latency.rows[0]) as
|
|
|
|
|
{ p50: string | number | null; p95: string | number | null } | undefined;
|
|
|
|
|
|
|
|
|
|
const latestErrors = await db.execute(sql`
|
|
|
|
|
SELECT properties->>'errorCode' AS code, occurred_at AS tid
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
WHERE event_name = 'scan_failed'
|
|
|
|
|
AND occurred_at >= now() - interval '24 hours'
|
|
|
|
|
ORDER BY occurred_at DESC
|
|
|
|
|
LIMIT 5
|
|
|
|
|
`);
|
|
|
|
|
const latestErrorsRows = (
|
|
|
|
|
Array.isArray(latestErrors) ? latestErrors : latestErrors.rows
|
|
|
|
|
) as Array<{
|
|
|
|
|
code: string | null;
|
|
|
|
|
tid: string | Date;
|
|
|
|
|
}>;
|
|
|
|
|
|
|
|
|
|
const month = await db.execute(sql`
|
|
|
|
|
SELECT COALESCE(sum(ai_cost_usd_microcents), 0)::bigint AS total
|
|
|
|
|
FROM ai_usage_counters
|
|
|
|
|
WHERE month = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM')
|
|
|
|
|
`);
|
|
|
|
|
const monthRow = (Array.isArray(month) ? month[0] : month.rows[0]) as
|
|
|
|
|
{ total: bigint | number } | undefined;
|
|
|
|
|
const monthlyCostMicrocents =
|
|
|
|
|
typeof monthRow?.total === "bigint" ? Number(monthRow.total) : asNumber(monthRow?.total);
|
|
|
|
|
|
|
|
|
|
const exact24h = await db.execute(sql`
|
|
|
|
|
SELECT COALESCE(sum(cost_usd), 0)::float AS total
|
|
|
|
|
FROM scan_jobs
|
|
|
|
|
WHERE cost_usd IS NOT NULL
|
|
|
|
|
AND updated_at >= now() - interval '24 hours'
|
|
|
|
|
AND status IN ('awaiting_confirmation', 'completed')
|
|
|
|
|
`);
|
|
|
|
|
const exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as
|
|
|
|
|
{ total: number } | undefined;
|
|
|
|
|
const exactCostUsd = asNumber(exact24hRow?.total);
|
|
|
|
|
|
|
|
|
|
let cost24hMicrocents: number | null = null;
|
|
|
|
|
let cost24hApprox = false;
|
|
|
|
|
if (exactCostUsd > 0) {
|
|
|
|
|
cost24hMicrocents = Math.round(exactCostUsd * MICROCENTS_PER_USD);
|
|
|
|
|
} else if (scans24h > 0) {
|
|
|
|
|
cost24hMicrocents = scans24h * ESTIMATED_SCAN_COST_MICROCENTS;
|
|
|
|
|
cost24hApprox = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let budgetAndel: number | null = null;
|
|
|
|
|
if (budgetUsd > 0 && dailySpendUsd != null && !Number.isNaN(dailySpendUsd)) {
|
|
|
|
|
budgetAndel = Math.min(1, dailySpendUsd / budgetUsd);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scans_24h: scans24h,
|
|
|
|
|
scans_7d: scans7d,
|
|
|
|
|
lyckandegrad_24h: roundRate(safeDiv(scans24h, started24h)),
|
|
|
|
|
latens_p50_ms: latencyRow?.p50 != null ? Math.round(Number(latencyRow.p50)) : null,
|
|
|
|
|
latens_p95_ms: latencyRow?.p95 != null ? Math.round(Number(latencyRow.p95)) : null,
|
|
|
|
|
felfrekvens_24h: roundRate(safeDiv(failed24h, started24h)),
|
|
|
|
|
senaste_fel:
|
|
|
|
|
latestErrorsRows.length > 0
|
|
|
|
|
? latestErrorsRows.map((r) => ({
|
|
|
|
|
kod: r.code,
|
|
|
|
|
tid: r.tid instanceof Date ? r.tid.toISOString() : String(r.tid),
|
|
|
|
|
}))
|
|
|
|
|
: null,
|
|
|
|
|
kostnad_usd_mikrocent_manad: monthlyCostMicrocents,
|
|
|
|
|
kostnad_usd_mikrocent_24h: cost24hMicrocents,
|
|
|
|
|
kostnad_usd_mikrocent_24h_uppskattad: cost24hApprox,
|
|
|
|
|
budget_andel: roundRate(budgetAndel),
|
|
|
|
|
as_of: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function conversionWithin(
|
|
|
|
|
db: Database,
|
|
|
|
|
fromEvent: string,
|
|
|
|
|
toEvent: string,
|
|
|
|
|
cohortDays: number,
|
|
|
|
|
withinHours: number,
|
|
|
|
|
): Promise<number | null> {
|
|
|
|
|
const result = await db.execute(sql`
|
|
|
|
|
WITH cohort AS (
|
|
|
|
|
SELECT DISTINCT user_id, occurred_at AS created_at
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
WHERE event_name = ${fromEvent}
|
|
|
|
|
AND occurred_at >= now() - make_interval(days => ${cohortDays})
|
|
|
|
|
AND user_id IS NOT NULL
|
|
|
|
|
),
|
|
|
|
|
converted AS (
|
|
|
|
|
SELECT DISTINCT c.user_id
|
|
|
|
|
FROM cohort c
|
|
|
|
|
INNER JOIN product_analytics_events e ON e.user_id = c.user_id
|
|
|
|
|
WHERE e.event_name = ${toEvent}
|
|
|
|
|
AND e.occurred_at >= c.created_at
|
|
|
|
|
AND e.occurred_at < c.created_at + make_interval(hours => ${withinHours})
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
(SELECT count(*) FROM cohort) AS total,
|
|
|
|
|
(SELECT count(*) FROM converted) AS converted
|
|
|
|
|
`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
|
|
|
|
{ total: number; converted: number } | undefined;
|
|
|
|
|
if (!row || asNumber(row.total) === 0) return null;
|
|
|
|
|
return asNumber(row.converted) / asNumber(row.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function householdConversionWithin(
|
|
|
|
|
db: Database,
|
|
|
|
|
fromEvent: string,
|
|
|
|
|
toEvent: string,
|
|
|
|
|
cohortDays: number,
|
|
|
|
|
): Promise<number | null> {
|
|
|
|
|
const result = await db.execute(sql`
|
|
|
|
|
WITH cohort AS (
|
|
|
|
|
SELECT DISTINCT household_id, occurred_at AS created_at
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
WHERE event_name = ${fromEvent}
|
|
|
|
|
AND occurred_at >= now() - make_interval(days => ${cohortDays})
|
|
|
|
|
AND household_id IS NOT NULL
|
|
|
|
|
),
|
|
|
|
|
converted AS (
|
|
|
|
|
SELECT DISTINCT c.household_id
|
|
|
|
|
FROM cohort c
|
|
|
|
|
INNER JOIN product_analytics_events e ON e.household_id = c.household_id
|
|
|
|
|
WHERE e.event_name = ${toEvent}
|
|
|
|
|
AND e.occurred_at >= c.created_at
|
|
|
|
|
AND e.occurred_at < c.created_at + make_interval(days => ${cohortDays})
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
(SELECT count(*) FROM cohort) AS total,
|
|
|
|
|
(SELECT count(*) FROM converted) AS converted
|
|
|
|
|
`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
|
|
|
|
{ total: number; converted: number } | undefined;
|
|
|
|
|
if (!row || asNumber(row.total) === 0) return null;
|
|
|
|
|
return asNumber(row.converted) / asNumber(row.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function cohortRetention(db: Database, day: number): Promise<number | null> {
|
|
|
|
|
const result = 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 >= now() - interval '30 days'
|
|
|
|
|
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}::int
|
|
|
|
|
)
|
|
|
|
|
SELECT
|
|
|
|
|
(SELECT count(DISTINCT user_id) FROM cohort) AS total,
|
|
|
|
|
(SELECT count(*) FROM active) AS active
|
|
|
|
|
`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
|
|
|
|
{ total: number; active: number } | undefined;
|
|
|
|
|
if (!row || asNumber(row.total) === 0) return null;
|
|
|
|
|
return asNumber(row.active) / asNumber(row.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function activationBlock(db: Database): Promise<OpsActivationBlock> {
|
|
|
|
|
const [scan24h, cook7d, householdSecond, d1, d7, d30] = await Promise.all([
|
|
|
|
|
conversionWithin(db, "account_created", "scan_completed", 7, 24),
|
|
|
|
|
conversionWithin(db, "account_created", "cooking_session_completed", 30, 24 * 7),
|
|
|
|
|
householdConversionWithin(db, "household_created", "second_member_first_action", 30),
|
|
|
|
|
cohortRetention(db, 1),
|
|
|
|
|
cohortRetention(db, 7),
|
|
|
|
|
cohortRetention(db, 30),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
scan_inom_24h_andel: roundRate(scan24h),
|
|
|
|
|
lagad_maltid_inom_7d_andel: roundRate(cook7d),
|
|
|
|
|
hushall_andra_medlem_aktiv_andel: roundRate(householdSecond),
|
|
|
|
|
retention_d1: roundRate(d1),
|
|
|
|
|
retention_d7: roundRate(d7),
|
|
|
|
|
retention_d30: roundRate(d30),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
|
|
|
|
|
const result = await db.execute(sql`
|
|
|
|
|
SELECT
|
|
|
|
|
count(*) FILTER (WHERE event_name = 'cooking_session_completed' AND occurred_at >= now() - interval '24 hours')::int AS c24,
|
|
|
|
|
count(*) FILTER (WHERE event_name = 'cooking_session_completed' AND occurred_at >= now() - interval '7 days')::int AS c7,
|
|
|
|
|
count(*) FILTER (WHERE event_name = 'recommendation_opened' AND occurred_at >= now() - interval '7 days')::int AS opened,
|
|
|
|
|
count(*) FILTER (WHERE event_name = 'recommendations_viewed' AND occurred_at >= now() - interval '7 days')::int AS viewed
|
|
|
|
|
FROM product_analytics_events
|
|
|
|
|
`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
|
|
|
|
{ c24: number; c7: number; opened: number; viewed: number } | undefined;
|
|
|
|
|
|
|
|
|
|
const tips = await db.execute(sql`
|
|
|
|
|
SELECT count(*)::int AS n
|
|
|
|
|
FROM notifications
|
|
|
|
|
WHERE type = 'proactive_tip'
|
|
|
|
|
AND created_at >= now() - interval '24 hours'
|
|
|
|
|
`);
|
|
|
|
|
const tipsRow = (Array.isArray(tips) ? tips[0] : tips.rows[0]) as { n: number } | undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
lagade_maltider_24h: asNumber(row?.c24),
|
|
|
|
|
lagade_maltider_7d: asNumber(row?.c7),
|
|
|
|
|
rek_ctr_7d: roundRate(safeDiv(asNumber(row?.opened), asNumber(row?.viewed))),
|
|
|
|
|
puffar_skickade_24h: asNumber(tipsRow?.n),
|
|
|
|
|
puffar_atgardsgrad_7d: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
|
|
|
|
|
const result = await db.execute(sql`
|
|
|
|
|
SELECT
|
|
|
|
|
count(*) FILTER (WHERE status = 'expired' OR (status != 'active' AND grace_period_expires_at < now()))::int AS failed,
|
|
|
|
|
count(*) FILTER (WHERE status = 'in_grace' OR (grace_period_expires_at IS NOT NULL AND grace_period_expires_at >= now() AND status != 'active'))::int AS grace
|
|
|
|
|
FROM subscriptions
|
|
|
|
|
`);
|
|
|
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
|
|
|
|
|
{ failed: number; grace: number } | undefined;
|
|
|
|
|
|
|
|
|
|
const trials = await db.execute(sql`
|
|
|
|
|
SELECT count(*)::int AS n
|
|
|
|
|
FROM trials
|
|
|
|
|
WHERE ends_at >= now()
|
|
|
|
|
AND ends_at <= now() + interval '48 hours'
|
|
|
|
|
`);
|
|
|
|
|
const trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as
|
|
|
|
|
{ n: number } | undefined;
|
|
|
|
|
|
|
|
|
|
const store = await db.execute(sql`
|
|
|
|
|
SELECT
|
|
|
|
|
count(*) FILTER (WHERE lower(notification_type) LIKE '%refund%')::int AS refunds,
|
|
|
|
|
count(*) FILTER (WHERE lower(notification_type) LIKE '%chargeback%' OR lower(notification_type) LIKE '%revoke%')::int AS chargebacks
|
|
|
|
|
FROM store_notifications
|
|
|
|
|
WHERE created_at >= now() - interval '7 days'
|
|
|
|
|
`);
|
|
|
|
|
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
|
|
|
|
|
{ refunds: number; chargebacks: number } | undefined;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
failed_nu: asNumber(row?.failed),
|
|
|
|
|
grace_period_nu: asNumber(row?.grace),
|
|
|
|
|
trials_utgar_48h: asNumber(trialsRow?.n),
|
|
|
|
|
aterbetalningar_7d: storeRow ? asNumber(storeRow.refunds) : null,
|
|
|
|
|
chargebacks_7d: storeRow ? asNumber(storeRow.chargebacks) : null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultQueueBlock(): OpsQueueBlock {
|
|
|
|
|
return {
|
|
|
|
|
vantande: 0,
|
|
|
|
|
aktiva: 0,
|
|
|
|
|
misslyckade_24h: 0,
|
|
|
|
|
aldsta_vantande_sek: null,
|
|
|
|
|
workers_ok: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function safetyBlock(db: Database): Promise<OpsSafetyBlock> {
|
|
|
|
|
const result = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(schema.opsSafetyCanary)
|
|
|
|
|
.orderBy(sql`${schema.opsSafetyCanary.occurredAt} DESC`)
|
|
|
|
|
.limit(1);
|
|
|
|
|
const row = result[0];
|
|
|
|
|
if (!row) {
|
|
|
|
|
return {
|
|
|
|
|
allergen_invariant_brott: 0,
|
|
|
|
|
overifierade_visade: 0,
|
|
|
|
|
food_safety_lint_avvisade_7d: null,
|
|
|
|
|
senaste_kontroll: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
allergen_invariant_brott: row.allergenInvariantBrott,
|
|
|
|
|
overifierade_visade: row.overifieradeVisade,
|
|
|
|
|
food_safety_lint_avvisade_7d: row.foodSafetyLintAvvisade7d,
|
|
|
|
|
senaste_kontroll: row.computedAt.toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
|
|
|
|
|
const { db, budgetUsd, dailySpendUsd, queueSummary } = options;
|
|
|
|
|
const [ai, activation, engagement, payment, safety] = await Promise.all([
|
|
|
|
|
aiScanBlock(db, budgetUsd, dailySpendUsd),
|
|
|
|
|
activationBlock(db),
|
|
|
|
|
engagementBlock(db),
|
|
|
|
|
paymentBlock(db),
|
|
|
|
|
safetyBlock(db),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ai_scan: ai,
|
|
|
|
|
aktivering: activation,
|
|
|
|
|
engagemang: engagement,
|
|
|
|
|
betalning: payment,
|
|
|
|
|
jobb: queueSummary ?? defaultQueueBlock(),
|
|
|
|
|
sakerhet: safety,
|
|
|
|
|
as_of: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
}
|