786 lines
26 KiB
TypeScript
786 lines
26 KiB
TypeScript
import { gte, sql } from "drizzle-orm";
|
|
import type {
|
|
OpsAppBlock,
|
|
OpsActivationBlock,
|
|
OpsAiScanBlock,
|
|
OpsEngagementBlock,
|
|
OpsPaymentBlock,
|
|
OpsQueueBlock,
|
|
OpsSafetyBlock,
|
|
OpsSummary,
|
|
OpsEconomyBlock,
|
|
OpsUsersBlock,
|
|
OpsSubscriptionsBlock,
|
|
OpsStoreBlock,
|
|
OpsFeedbackBlock,
|
|
OpsWallBlock,
|
|
OpsWallTile,
|
|
OpsAlarm,
|
|
OpsAlarmBlock,
|
|
OpsGeminiBlock,
|
|
} from "@app/shared-types";
|
|
import { SUBSCRIPTION_PLANS, type SubscriptionPlan } from "@app/shared-types";
|
|
import type { Database } from "./client.js";
|
|
import { schema } from "./index.js";
|
|
import { geminiUsage } from "./schema/index.js";
|
|
|
|
const BUDGET_KEY_PREFIX = "gemini:daily:budget:";
|
|
|
|
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;
|
|
/** Optional override for subscription plan prices in SEK öre per month. */
|
|
planPrices?: Partial<Record<SubscriptionPlan, number | null>>;
|
|
}
|
|
|
|
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 formatSekKr(oer: number | null): string {
|
|
if (oer === null || Number.isNaN(oer)) return "—";
|
|
const kr = oer / 100;
|
|
// Mellanslag (0x20) som tusentalsavgränsare, utan ören.
|
|
const formatted = new Intl.NumberFormat("sv-SE", { maximumFractionDigits: 0 }).format(kr);
|
|
return `${formatted.replace(/\s/g, " ")} kr`;
|
|
}
|
|
|
|
function formatPercent(ratio: number | null): string {
|
|
if (ratio === null || Number.isNaN(ratio)) return "—";
|
|
return `${Math.round(ratio * 100)} %`;
|
|
}
|
|
|
|
function formatInt(n: number | null): string {
|
|
if (n === null || Number.isNaN(n)) return "—";
|
|
return String(n);
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
function appBlock(): OpsAppBlock {
|
|
return { app: "cibello", generated_at: new Date().toISOString() };
|
|
}
|
|
|
|
function getPlanPrices(): Record<SubscriptionPlan, number | null> {
|
|
const defaults = Object.fromEntries(SUBSCRIPTION_PLANS.map((p) => [p, null])) as Record<
|
|
SubscriptionPlan,
|
|
number | null
|
|
>;
|
|
const envJson = process.env.SUBSCRIPTION_PLAN_PRICES_JSON;
|
|
if (envJson) {
|
|
try {
|
|
const parsed = JSON.parse(envJson) as Record<string, number | null>;
|
|
for (const plan of SUBSCRIPTION_PLANS) {
|
|
if (parsed[plan] !== undefined) defaults[plan] = parsed[plan];
|
|
}
|
|
} catch {
|
|
// Ignorera trasig env-JSON; använd null för alla planer.
|
|
}
|
|
}
|
|
return defaults;
|
|
}
|
|
|
|
async function economyBlock(
|
|
db: Database,
|
|
planPrices: Record<SubscriptionPlan, number | null>,
|
|
): Promise<OpsEconomyBlock> {
|
|
const active = await db.execute(sql`
|
|
SELECT plan, count(*)::int AS n
|
|
FROM subscriptions
|
|
WHERE status = 'active'
|
|
AND (expires_at IS NULL OR expires_at > now())
|
|
GROUP BY plan
|
|
`);
|
|
const activeRows = (Array.isArray(active) ? active : active.rows) as Array<{
|
|
plan: SubscriptionPlan;
|
|
n: number;
|
|
}>;
|
|
let mrr: number | null = 0;
|
|
for (const row of activeRows) {
|
|
const price = planPrices[row.plan];
|
|
if (price === null) {
|
|
mrr = null;
|
|
break;
|
|
}
|
|
mrr = (mrr ?? 0) + price * row.n;
|
|
}
|
|
|
|
async function revenueFor(hours: number): Promise<number | null> {
|
|
const events = await db.execute(sql`
|
|
SELECT properties
|
|
FROM product_analytics_events
|
|
WHERE event_name = 'subscription_started'
|
|
AND occurred_at >= now() - make_interval(hours => ${hours})
|
|
`);
|
|
const rows = (Array.isArray(events) ? events : events.rows) as Array<{
|
|
properties: { priceOer?: number; plan?: SubscriptionPlan } | null;
|
|
}>;
|
|
let total: number | null = 0;
|
|
for (const row of rows) {
|
|
const props = row.properties ?? {};
|
|
const price =
|
|
typeof props.priceOer === "number" ? props.priceOer : planPrices[props.plan ?? "free"];
|
|
if (price === null) {
|
|
total = null;
|
|
break;
|
|
}
|
|
total = (total ?? 0) + price;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
const [intakt24h, intakt7d] = await Promise.all([revenueFor(24), revenueFor(24 * 7)]);
|
|
|
|
return {
|
|
mrr,
|
|
intakt_24h: intakt24h,
|
|
intakt_7d: intakt7d,
|
|
valuta: "SEK",
|
|
};
|
|
}
|
|
|
|
async function usersBlock(db: Database): Promise<OpsUsersBlock> {
|
|
const result = await db.execute(sql`
|
|
SELECT
|
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(mins => 5))::int AS aktiva_nu,
|
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(hours => 24))::int AS dau,
|
|
count(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL AND occurred_at >= now() - make_interval(days => 30))::int AS mau,
|
|
count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(hours => 24))::int AS nya_24h,
|
|
count(*) FILTER (WHERE event_name = 'account_created' AND occurred_at >= now() - make_interval(days => 7))::int AS nya_7d
|
|
FROM product_analytics_events
|
|
`);
|
|
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as {
|
|
aktiva_nu: number;
|
|
dau: number;
|
|
mau: number;
|
|
nya_24h: number;
|
|
nya_7d: number;
|
|
};
|
|
return {
|
|
aktiva_nu: asNumber(row.aktiva_nu),
|
|
dau: asNumber(row.dau),
|
|
mau: asNumber(row.mau),
|
|
nya_24h: asNumber(row.nya_24h),
|
|
nya_7d: asNumber(row.nya_7d),
|
|
};
|
|
}
|
|
|
|
async function subscriptionsBlock(db: Database): Promise<OpsSubscriptionsBlock> {
|
|
const [trials, paying] = await Promise.all([
|
|
db.execute(sql`SELECT count(*)::int AS n FROM trials WHERE ends_at >= now()`),
|
|
db.execute(sql`
|
|
SELECT count(*)::int AS n
|
|
FROM subscriptions
|
|
WHERE status = 'active' AND (expires_at IS NULL OR expires_at > now())
|
|
`),
|
|
]);
|
|
const trialAktiva = asNumber((Array.isArray(trials) ? trials[0] : trials.rows[0]).n);
|
|
const betalande = asNumber((Array.isArray(paying) ? paying[0] : paying.rows[0]).n);
|
|
|
|
const trialConv24h = await db.execute(sql`
|
|
SELECT count(DISTINCT t.user_id)::int AS n
|
|
FROM product_analytics_events t
|
|
INNER JOIN product_analytics_events s ON s.user_id = t.user_id
|
|
WHERE t.event_name = 'trial_started'
|
|
AND s.event_name = 'subscription_started'
|
|
AND s.occurred_at >= now() - make_interval(hours => 24)
|
|
AND s.occurred_at >= t.occurred_at
|
|
`);
|
|
const trialConv7d = await db.execute(sql`
|
|
SELECT count(DISTINCT t.user_id)::int AS n
|
|
FROM product_analytics_events t
|
|
INNER JOIN product_analytics_events s ON s.user_id = t.user_id
|
|
WHERE t.event_name = 'trial_started'
|
|
AND s.event_name = 'subscription_started'
|
|
AND s.occurred_at >= now() - make_interval(days => 7)
|
|
AND s.occurred_at >= t.occurred_at
|
|
`);
|
|
|
|
const konv30d = await conversionWithin(db, "trial_started", "subscription_started", 30, 30 * 24);
|
|
|
|
const avslutade = await db.execute(sql`
|
|
SELECT count(*)::int AS n
|
|
FROM subscription_events
|
|
WHERE event_type IN ('expired', 'cancelled')
|
|
AND created_at >= now() - make_interval(hours => 24)
|
|
`);
|
|
|
|
return {
|
|
trial_aktiva: trialAktiva,
|
|
trial_konverterade_24h: asNumber(
|
|
(Array.isArray(trialConv24h) ? trialConv24h[0] : trialConv24h.rows[0]).n,
|
|
),
|
|
trial_konverterade_7d: asNumber(
|
|
(Array.isArray(trialConv7d) ? trialConv7d[0] : trialConv7d.rows[0]).n,
|
|
),
|
|
konverteringsgrad_30d: roundRate(konv30d),
|
|
betalande,
|
|
avslutade_24h: asNumber((Array.isArray(avslutade) ? avslutade[0] : avslutade.rows[0]).n),
|
|
};
|
|
}
|
|
|
|
function storeBlock(): OpsStoreBlock {
|
|
return { butik: null, all_fields_phase_2: null };
|
|
}
|
|
|
|
export function buildWall(summary: OpsSummary): OpsWallBlock {
|
|
const budgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0);
|
|
const dailySpendUsd =
|
|
summary.ai_scan.budget_andel != null && budgetUsd > 0
|
|
? summary.ai_scan.budget_andel * budgetUsd
|
|
: null;
|
|
const budgetLabel =
|
|
dailySpendUsd != null && budgetUsd > 0 ? `${dailySpendUsd.toFixed(2)} / ${budgetUsd} USD` : "—";
|
|
const budgetTone: OpsWallTile["tone"] =
|
|
budgetUsd <= 0
|
|
? "ok"
|
|
: summary.ai_scan.budget_andel == null
|
|
? "warn"
|
|
: summary.ai_scan.budget_andel >= 1
|
|
? "crit"
|
|
: summary.ai_scan.budget_andel >= 0.8
|
|
? "warn"
|
|
: "ok";
|
|
|
|
const tiles: OpsWallTile[] = [
|
|
{
|
|
label: "MRR",
|
|
value: formatSekKr(summary.ekonomi.mrr),
|
|
tone: summary.ekonomi.mrr === null ? "warn" : "ok",
|
|
},
|
|
{
|
|
label: "Aktiva nu",
|
|
value: formatInt(summary.anvandare.aktiva_nu),
|
|
tone: "ok",
|
|
},
|
|
{
|
|
label: "Betalande",
|
|
value: formatInt(summary.prenumerationer.betalande),
|
|
tone: "ok",
|
|
},
|
|
{
|
|
label: "Trial-konv",
|
|
value: formatPercent(summary.prenumerationer.konverteringsgrad_30d),
|
|
tone: summary.prenumerationer.konverteringsgrad_30d === null ? "warn" : "ok",
|
|
},
|
|
{
|
|
label: "AI-budget",
|
|
value: budgetLabel,
|
|
tone: budgetTone,
|
|
},
|
|
{
|
|
label: "Workers",
|
|
value: summary.jobb.workers_ok ? "OK" : "NERE",
|
|
tone: summary.jobb.workers_ok ? "ok" : "warn",
|
|
},
|
|
{
|
|
label: "Allergen-brott",
|
|
value: formatInt(summary.sakerhet.allergen_invariant_brott),
|
|
tone: summary.sakerhet.allergen_invariant_brott === 0 ? "ok" : "warn",
|
|
},
|
|
];
|
|
|
|
return {
|
|
boards: [{ title: "{brand}", tiles }],
|
|
};
|
|
}
|
|
|
|
async function feedbackBlock(db: Database): Promise<OpsFeedbackBlock> {
|
|
const counts = await db.execute(sql`
|
|
SELECT
|
|
count(*) FILTER (WHERE status = 'oppen')::int AS oppna,
|
|
count(*) FILTER (WHERE created_at >= now() - make_interval(hours => 24))::int AS nya_24h
|
|
FROM feedback
|
|
`);
|
|
const countsRow = (Array.isArray(counts) ? counts[0] : counts.rows[0]) as {
|
|
oppna: number;
|
|
nya_24h: number;
|
|
};
|
|
|
|
const latest = await db
|
|
.select({
|
|
rubrik: schema.feedback.rubrik,
|
|
typ: schema.feedback.typ,
|
|
status: schema.feedback.status,
|
|
createdAt: schema.feedback.createdAt,
|
|
})
|
|
.from(schema.feedback)
|
|
.orderBy(sql`${schema.feedback.createdAt} DESC`)
|
|
.limit(10);
|
|
|
|
return {
|
|
oppna: asNumber(countsRow.oppna),
|
|
nya_24h: asNumber(countsRow.nya_24h),
|
|
senaste:
|
|
latest.length > 0
|
|
? latest.map((f) => ({
|
|
rubrik: f.rubrik,
|
|
typ: f.typ,
|
|
status: f.status,
|
|
created_at: f.createdAt.toISOString(),
|
|
}))
|
|
: null,
|
|
};
|
|
}
|
|
|
|
async function geminiBlock(db: Database): Promise<OpsGeminiBlock> {
|
|
const defaults: OpsGeminiBlock = {
|
|
anrop_24h: null,
|
|
prompt_tokens_24h: null,
|
|
output_tokens_24h: null,
|
|
total_tokens_24h: null,
|
|
total_tokens_manad: null,
|
|
kostnad_usd_mikrocent_24h: null,
|
|
andel_pa_aamos: 0.0,
|
|
aamos_agreement: null,
|
|
};
|
|
|
|
try {
|
|
const now = new Date();
|
|
const since24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
const [dayAgg, monthAgg] = await Promise.all([
|
|
db
|
|
.select({
|
|
count: sql<number>`count(*)`,
|
|
promptTokens: sql<number>`coalesce(sum(${geminiUsage.promptTokens}), 0)`,
|
|
outputTokens: sql<number>`coalesce(sum(${geminiUsage.outputTokens}), 0)`,
|
|
totalTokens: sql<number>`coalesce(sum(${geminiUsage.totalTokens}), 0)`,
|
|
costMicrocents: sql<number>`coalesce(sum(${geminiUsage.costMicrocents}), 0)`,
|
|
})
|
|
.from(geminiUsage)
|
|
.where(gte(geminiUsage.createdAt, since24h)),
|
|
db
|
|
.select({
|
|
totalTokens: sql<number>`coalesce(sum(${geminiUsage.totalTokens}), 0)`,
|
|
})
|
|
.from(geminiUsage)
|
|
.where(gte(geminiUsage.createdAt, startOfMonth)),
|
|
]);
|
|
|
|
return {
|
|
anrop_24h: Number(dayAgg[0]?.count ?? 0),
|
|
prompt_tokens_24h: Number(dayAgg[0]?.promptTokens ?? 0),
|
|
output_tokens_24h: Number(dayAgg[0]?.outputTokens ?? 0),
|
|
total_tokens_24h: Number(dayAgg[0]?.totalTokens ?? 0),
|
|
total_tokens_manad: Number(monthAgg[0]?.totalTokens ?? 0),
|
|
kostnad_usd_mikrocent_24h: Number(dayAgg[0]?.costMicrocents ?? 0),
|
|
andel_pa_aamos: 0.0,
|
|
aamos_agreement: null,
|
|
};
|
|
} catch {
|
|
return defaults;
|
|
}
|
|
}
|
|
|
|
function alarmBlock(budgetUsd: number, dailySpendUsd: number | null | undefined): OpsAlarmBlock {
|
|
const alarms: OpsAlarm[] = [];
|
|
if (budgetUsd > 0 && dailySpendUsd != null && dailySpendUsd >= budgetUsd) {
|
|
alarms.push({
|
|
typ: "budget_exceeded",
|
|
rubrik: "Gemini dagsbudget förbrukad",
|
|
detalj: `Dagens spend ${dailySpendUsd.toFixed(4)} USD når eller överskrider taket ${budgetUsd} USD. Inga nya AI-jobb körs tills imorgon.`,
|
|
allvarlighetsgrad: "crit",
|
|
created_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
return { larm: alarms };
|
|
}
|
|
|
|
export async function computeOpsSummary(options: ComputeOpsSummaryOptions): Promise<OpsSummary> {
|
|
const { db, budgetUsd, dailySpendUsd, queueSummary, planPrices: planPricesOverride } = options;
|
|
const planPrices = { ...getPlanPrices(), ...(planPricesOverride ?? {}) };
|
|
const [
|
|
app,
|
|
economy,
|
|
users,
|
|
subscriptions,
|
|
butik,
|
|
feedbackData,
|
|
ai,
|
|
activation,
|
|
engagement,
|
|
payment,
|
|
safety,
|
|
larm,
|
|
gemini,
|
|
] = await Promise.all([
|
|
Promise.resolve(appBlock()),
|
|
economyBlock(db, planPrices),
|
|
usersBlock(db),
|
|
subscriptionsBlock(db),
|
|
Promise.resolve(storeBlock()),
|
|
feedbackBlock(db),
|
|
aiScanBlock(db, budgetUsd, dailySpendUsd),
|
|
activationBlock(db),
|
|
engagementBlock(db),
|
|
paymentBlock(db),
|
|
safetyBlock(db),
|
|
Promise.resolve(alarmBlock(budgetUsd, dailySpendUsd)),
|
|
geminiBlock(db),
|
|
]);
|
|
|
|
const base: OpsSummary = {
|
|
app,
|
|
ekonomi: economy,
|
|
anvandare: users,
|
|
prenumerationer: subscriptions,
|
|
butik,
|
|
feedback: feedbackData,
|
|
ai_scan: ai,
|
|
aktivering: activation,
|
|
engagemang: engagement,
|
|
betalning: payment,
|
|
jobb: queueSummary ?? defaultQueueBlock(),
|
|
sakerhet: safety,
|
|
larm,
|
|
wall: { boards: [] },
|
|
gemini,
|
|
as_of: new Date().toISOString(),
|
|
};
|
|
|
|
base.wall = buildWall(base);
|
|
|
|
return base;
|
|
}
|