ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s

This commit is contained in:
Sven (AAMOS AI)
2026-08-13 17:25:14 +07:00
parent 61d60931ad
commit c32a7e33c7
141 changed files with 40555 additions and 45210 deletions
+16 -12
View File
@@ -84,8 +84,10 @@ export async function markMilestone(
const isValueConfirmedNow =
isActivatedNow &&
(before.firstCookingSessionCompletedAt != null || milestone === "firstCookingSessionCompletedAt") &&
(before.inventoryUpdatedAfterCookingAt != null || milestone === "inventoryUpdatedAfterCookingAt");
(before.firstCookingSessionCompletedAt != null ||
milestone === "firstCookingSessionCompletedAt") &&
(before.inventoryUpdatedAfterCookingAt != null ||
milestone === "inventoryUpdatedAfterCookingAt");
const valueConfirmedNow = !wasValueConfirmed && isValueConfirmedNow;
@@ -121,14 +123,16 @@ export async function getActivationState(db: Db, householdId: string): Promise<A
.from(schema.households)
.where(eq(schema.households.id, householdId));
return row ?? {
firstScanCompletedAt: null,
fifthItemConfirmedAt: null,
firstRecipeRecommendationViewedAt: null,
firstRecipeSavedOrStartedAt: null,
activatedAt: null,
firstCookingSessionCompletedAt: null,
inventoryUpdatedAfterCookingAt: null,
valueConfirmedAt: null,
};
return (
row ?? {
firstScanCompletedAt: null,
fifthItemConfirmedAt: null,
firstRecipeRecommendationViewedAt: null,
firstRecipeSavedOrStartedAt: null,
activatedAt: null,
firstCookingSessionCompletedAt: null,
inventoryUpdatedAfterCookingAt: null,
valueConfirmedAt: null,
}
);
}
+5 -2
View File
@@ -35,7 +35,8 @@ function stripSslmode(url: string): string {
*/
export function createDatabase(connectionString?: string) {
const isTest = process.env.NODE_ENV === "test" || process.env.VITEST !== undefined;
const rawUrl = connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL);
const rawUrl =
connectionString ?? (isTest ? process.env.TEST_DATABASE_URL : process.env.DATABASE_URL);
if (!rawUrl) {
if (isTest) {
@@ -43,7 +44,9 @@ export function createDatabase(connectionString?: string) {
"Sätt TEST_DATABASE_URL till en dedikerad testdatabas. Tester får aldrig använda DATABASE_URL.",
);
}
throw new Error("Missing DATABASE_URL. Set it in your .env or environment before starting the app.");
throw new Error(
"Missing DATABASE_URL. Set it in your .env or environment before starting the app.",
);
}
const url = stripSslmode(rawUrl);
+217 -61
View File
@@ -37,7 +37,10 @@ export async function buildErasurePlan(db: Database, userId: string): Promise<Er
const bankImages = await db
.select({ key: schema.aiTrainingBank.imageS3Key })
.from(schema.aiTrainingBank)
.innerJoin(schema.aiCorrections, eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id))
.innerJoin(
schema.aiCorrections,
eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id),
)
.where(eq(schema.aiCorrections.userId, userId));
for (const row of bankImages) {
if (row.key) imageReferences.push(row.key);
@@ -101,12 +104,7 @@ export async function buildErasurePlan(db: Database, userId: string): Promise<Er
const draftRecipeImages = await db
.select({ urls: schema.recipes.imageUrls })
.from(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
);
.where(and(eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published")));
for (const row of draftRecipeImages) {
for (const url of row.urls ?? []) {
if (url) imageReferences.push(url);
@@ -128,167 +126,261 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.delete(schema.userCredentials)
.where(eq(schema.userCredentials.userId, userId))
.returning({ userId: schema.userCredentials.userId });
if (credDel.length) log.push({ table: getTableName(schema.userCredentials), action: "delete", count: credDel.length });
if (credDel.length)
log.push({
table: getTableName(schema.userCredentials),
action: "delete",
count: credDel.length,
});
const adminTotpDel = await db
.delete(schema.adminTotp)
.where(eq(schema.adminTotp.userId, userId))
.returning({ userId: schema.adminTotp.userId });
if (adminTotpDel.length) log.push({ table: getTableName(schema.adminTotp), action: "delete", count: adminTotpDel.length });
if (adminTotpDel.length)
log.push({
table: getTableName(schema.adminTotp),
action: "delete",
count: adminTotpDel.length,
});
const emailTokensDel = await db
.delete(schema.emailVerificationTokens)
.where(eq(schema.emailVerificationTokens.userId, userId))
.returning({ id: schema.emailVerificationTokens.id });
if (emailTokensDel.length) log.push({ table: getTableName(schema.emailVerificationTokens), action: "delete", count: emailTokensDel.length });
if (emailTokensDel.length)
log.push({
table: getTableName(schema.emailVerificationTokens),
action: "delete",
count: emailTokensDel.length,
});
const pwTokensDel = await db
.delete(schema.passwordResetTokens)
.where(eq(schema.passwordResetTokens.userId, userId))
.returning({ id: schema.passwordResetTokens.id });
if (pwTokensDel.length) log.push({ table: getTableName(schema.passwordResetTokens), action: "delete", count: pwTokensDel.length });
if (pwTokensDel.length)
log.push({
table: getTableName(schema.passwordResetTokens),
action: "delete",
count: pwTokensDel.length,
});
const refreshDel = await db
.delete(schema.refreshTokens)
.where(eq(schema.refreshTokens.userId, userId))
.returning({ id: schema.refreshTokens.id });
if (refreshDel.length) log.push({ table: getTableName(schema.refreshTokens), action: "delete", count: refreshDel.length });
if (refreshDel.length)
log.push({
table: getTableName(schema.refreshTokens),
action: "delete",
count: refreshDel.length,
});
const consentsDel = await db
.delete(schema.userConsents)
.where(eq(schema.userConsents.userId, userId))
.returning({ userId: schema.userConsents.userId });
if (consentsDel.length) log.push({ table: getTableName(schema.userConsents), action: "delete", count: consentsDel.length });
if (consentsDel.length)
log.push({
table: getTableName(schema.userConsents),
action: "delete",
count: consentsDel.length,
});
const idemDel = await db
.delete(schema.idempotencyKeys)
.where(eq(schema.idempotencyKeys.userId, userId))
.returning({ key: schema.idempotencyKeys.key });
if (idemDel.length) log.push({ table: getTableName(schema.idempotencyKeys), action: "delete", count: idemDel.length });
if (idemDel.length)
log.push({
table: getTableName(schema.idempotencyKeys),
action: "delete",
count: idemDel.length,
});
const pushDel = await db
.delete(schema.pushTokens)
.where(eq(schema.pushTokens.userId, userId))
.returning({ token: schema.pushTokens.token });
if (pushDel.length) log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length });
if (pushDel.length)
log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length });
const notifDel = await db
.delete(schema.notifications)
.where(eq(schema.notifications.userId, userId))
.returning({ id: schema.notifications.id });
if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length });
if (notifDel.length)
log.push({
table: getTableName(schema.notifications),
action: "delete",
count: notifDel.length,
});
const feedbackDel = await db
.delete(schema.feedback)
.where(eq(schema.feedback.userId, userId))
.returning({ id: schema.feedback.id });
if (feedbackDel.length) log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
if (feedbackDel.length)
log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length });
// ---- 2. Recipes ----
const deletedRecipes = await db
.delete(schema.recipes)
.where(
and(
eq(schema.recipes.creatorUserId, userId),
ne(schema.recipes.status, "published"),
),
)
.where(and(eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published")))
.returning({ id: schema.recipes.id });
if (deletedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "delete", count: deletedRecipes.length });
if (deletedRecipes.length)
log.push({
table: getTableName(schema.recipes),
action: "delete",
count: deletedRecipes.length,
});
const anonymizedRecipes = await db
.update(schema.recipes)
.set({ creatorUserId: null, creatorDisplayName: null, updatedAt: new Date() })
.where(
and(
eq(schema.recipes.creatorUserId, userId),
eq(schema.recipes.status, "published"),
),
)
.where(and(eq(schema.recipes.creatorUserId, userId), eq(schema.recipes.status, "published")))
.returning({ id: schema.recipes.id });
if (anonymizedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "anonymize", count: anonymizedRecipes.length });
if (anonymizedRecipes.length)
log.push({
table: getTableName(schema.recipes),
action: "anonymize",
count: anonymizedRecipes.length,
});
const ratingsDel = await db
.delete(schema.recipeRatings)
.where(eq(schema.recipeRatings.userId, userId))
.returning({ id: schema.recipeRatings.id });
if (ratingsDel.length) log.push({ table: getTableName(schema.recipeRatings), action: "delete", count: ratingsDel.length });
if (ratingsDel.length)
log.push({
table: getTableName(schema.recipeRatings),
action: "delete",
count: ratingsDel.length,
});
const favoritesDel = await db
.delete(schema.recipeFavorites)
.where(eq(schema.recipeFavorites.userId, userId))
.returning({ recipeId: schema.recipeFavorites.recipeId });
if (favoritesDel.length) log.push({ table: getTableName(schema.recipeFavorites), action: "delete", count: favoritesDel.length });
if (favoritesDel.length)
log.push({
table: getTableName(schema.recipeFavorites),
action: "delete",
count: favoritesDel.length,
});
const cooksDel = await db
.delete(schema.recipeCooks)
.where(eq(schema.recipeCooks.userId, userId))
.returning({ id: schema.recipeCooks.id });
if (cooksDel.length) log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length });
if (cooksDel.length)
log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length });
const creatorStatsDel = await db
.delete(schema.creatorStats)
.where(eq(schema.creatorStats.userId, userId))
.returning({ userId: schema.creatorStats.userId });
if (creatorStatsDel.length) log.push({ table: getTableName(schema.creatorStats), action: "delete", count: creatorStatsDel.length });
if (creatorStatsDel.length)
log.push({
table: getTableName(schema.creatorStats),
action: "delete",
count: creatorStatsDel.length,
});
const followsDel = await db
.delete(schema.creatorFollows)
.where(
or(eq(schema.creatorFollows.followerUserId, userId), eq(schema.creatorFollows.creatorUserId, userId)),
or(
eq(schema.creatorFollows.followerUserId, userId),
eq(schema.creatorFollows.creatorUserId, userId),
),
)
.returning({ followerUserId: schema.creatorFollows.followerUserId });
if (followsDel.length) log.push({ table: getTableName(schema.creatorFollows), action: "delete", count: followsDel.length });
if (followsDel.length)
log.push({
table: getTableName(schema.creatorFollows),
action: "delete",
count: followsDel.length,
});
// ---- 3. Personal content ----
const mealsDel = await db
.delete(schema.meals)
.where(eq(schema.meals.userId, userId))
.returning({ id: schema.meals.id });
if (mealsDel.length) log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length });
if (mealsDel.length)
log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length });
const foodMemDel = await db
.delete(schema.foodMemories)
.where(eq(schema.foodMemories.userId, userId))
.returning({ id: schema.foodMemories.id });
if (foodMemDel.length) log.push({ table: getTableName(schema.foodMemories), action: "delete", count: foodMemDel.length });
if (foodMemDel.length)
log.push({
table: getTableName(schema.foodMemories),
action: "delete",
count: foodMemDel.length,
});
const tasteDel = await db
.delete(schema.tasteSignals)
.where(eq(schema.tasteSignals.userId, userId))
.returning({ id: schema.tasteSignals.id });
if (tasteDel.length) log.push({ table: getTableName(schema.tasteSignals), action: "delete", count: tasteDel.length });
if (tasteDel.length)
log.push({
table: getTableName(schema.tasteSignals),
action: "delete",
count: tasteDel.length,
});
const memoryDel = await db
.delete(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId))
.returning({ id: schema.memoryItems.id });
if (memoryDel.length) log.push({ table: getTableName(schema.memoryItems), action: "delete", count: memoryDel.length });
if (memoryDel.length)
log.push({
table: getTableName(schema.memoryItems),
action: "delete",
count: memoryDel.length,
});
// ---- 4. AI / scans / training ----
const aiCorrectionsDel = await db
.delete(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId))
.returning({ id: schema.aiCorrections.id });
if (aiCorrectionsDel.length) log.push({ table: getTableName(schema.aiCorrections), action: "delete", count: aiCorrectionsDel.length });
if (aiCorrectionsDel.length)
log.push({
table: getTableName(schema.aiCorrections),
action: "delete",
count: aiCorrectionsDel.length,
});
const scanJobsDel = await db
.delete(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId))
.returning({ id: schema.scanJobs.id });
if (scanJobsDel.length) log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length });
if (scanJobsDel.length)
log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length });
const aiUsageDel = await db
.delete(schema.aiUsageCounters)
.where(eq(schema.aiUsageCounters.userId, userId))
.returning({ userId: schema.aiUsageCounters.userId });
if (aiUsageDel.length) log.push({ table: getTableName(schema.aiUsageCounters), action: "delete", count: aiUsageDel.length });
if (aiUsageDel.length)
log.push({
table: getTableName(schema.aiUsageCounters),
action: "delete",
count: aiUsageDel.length,
});
const trialsDel = await db
.delete(schema.trials)
.where(eq(schema.trials.userId, userId))
.returning({ userId: schema.trials.userId });
if (trialsDel.length) log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length });
if (trialsDel.length)
log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length });
// ---- 5. Financial / subscriptions (pseudonymize) ----
const subCountRow = await db
@@ -299,7 +391,8 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.select({ count: sql<number>`count(*)::int` })
.from(schema.storeTransactions)
.where(eq(schema.storeTransactions.userId, userId));
const hasFinancialRecords = Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0;
const hasFinancialRecords =
Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0;
if (hasFinancialRecords) {
const pseudonym = randomUUID();
@@ -317,7 +410,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ userId: null, pseudonym, householdId: null, updatedAt: new Date() })
.where(eq(schema.subscriptions.userId, userId))
.returning({ id: schema.subscriptions.id });
if (subPseud.length) log.push({ table: getTableName(schema.subscriptions), action: "pseudonymize", count: subPseud.length });
if (subPseud.length)
log.push({
table: getTableName(schema.subscriptions),
action: "pseudonymize",
count: subPseud.length,
});
const storePseud = await db
.update(schema.storeTransactions)
@@ -328,14 +426,24 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
})
.where(eq(schema.storeTransactions.userId, userId))
.returning({ id: schema.storeTransactions.id });
if (storePseud.length) log.push({ table: getTableName(schema.storeTransactions), action: "pseudonymize", count: storePseud.length });
if (storePseud.length)
log.push({
table: getTableName(schema.storeTransactions),
action: "pseudonymize",
count: storePseud.length,
});
const eventPseud = await db
.update(schema.subscriptionEvents)
.set({ userId: null, pseudonym, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.subscriptionEvents.userId, userId))
.returning({ id: schema.subscriptionEvents.id });
if (eventPseud.length) log.push({ table: getTableName(schema.subscriptionEvents), action: "pseudonymize", count: eventPseud.length });
if (eventPseud.length)
log.push({
table: getTableName(schema.subscriptionEvents),
action: "pseudonymize",
count: eventPseud.length,
});
}
// ---- 6. Audit logs (anonymize actor, keep for retention) ----
@@ -344,7 +452,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ actorUserId: null, ip: null, metadata: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.auditLogs.actorUserId, userId))
.returning({ id: schema.auditLogs.id });
if (auditAnon.length) log.push({ table: getTableName(schema.auditLogs), action: "anonymize", count: auditAnon.length });
if (auditAnon.length)
log.push({
table: getTableName(schema.auditLogs),
action: "anonymize",
count: auditAnon.length,
});
// ---- 7. Domain events (anonymize) ----
const domainAnon = await db
@@ -352,7 +465,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ userId: null, payload: sql`jsonb_build_object('anonymized', true)` })
.where(eq(schema.domainEvents.userId, userId))
.returning({ id: schema.domainEvents.id });
if (domainAnon.length) log.push({ table: getTableName(schema.domainEvents), action: "anonymize", count: domainAnon.length });
if (domainAnon.length)
log.push({
table: getTableName(schema.domainEvents),
action: "anonymize",
count: domainAnon.length,
});
// ---- 8. Households ----
// Remove memberships first; remember surviving households for receipt anonymization.
@@ -366,7 +484,8 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.delete(schema.households)
.where(inArray(schema.households.id, plan.householdsToDelete))
.returning({ id: schema.households.id });
if (hhDel.length) log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length });
if (hhDel.length)
log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length });
}
const survivingHouseholdIds = survivingHouseholds
@@ -380,11 +499,20 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.where(inArray(schema.receipts.householdId, survivingHouseholdIds))
.returning({ id: schema.receipts.id });
if (receiptAnon.length) {
log.push({ table: getTableName(schema.receipts), action: "anonymize", count: receiptAnon.length });
log.push({
table: getTableName(schema.receipts),
action: "anonymize",
count: receiptAnon.length,
});
await db
.update(schema.receiptLines)
.set({ rawText: "" })
.where(inArray(schema.receiptLines.receiptId, receiptAnon.map((r) => r.id)));
.where(
inArray(
schema.receiptLines.receiptId,
receiptAnon.map((r) => r.id),
),
);
}
// Anonymize cooking sessions started by user in surviving households.
@@ -393,7 +521,12 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ startedByUserId: null })
.where(eq(schema.cookingSessions.startedByUserId, userId))
.returning({ id: schema.cookingSessions.id });
if (cookingAnon.length) log.push({ table: getTableName(schema.cookingSessions), action: "anonymize", count: cookingAnon.length });
if (cookingAnon.length)
log.push({
table: getTableName(schema.cookingSessions),
action: "anonymize",
count: cookingAnon.length,
});
// Anonymize household-level records that still reference the user.
const conflictAnon = await db
@@ -401,38 +534,61 @@ export async function eraseUser(db: Database, userId: string): Promise<ErasureLo
.set({ resolvedByUserId: null })
.where(eq(schema.inventoryConflicts.resolvedByUserId, userId))
.returning({ id: schema.inventoryConflicts.id });
if (conflictAnon.length) log.push({ table: getTableName(schema.inventoryConflicts), action: "anonymize", count: conflictAnon.length });
if (conflictAnon.length)
log.push({
table: getTableName(schema.inventoryConflicts),
action: "anonymize",
count: conflictAnon.length,
});
const transactionAnon = await db
.update(schema.inventoryTransactions)
.set({ actorUserId: null })
.where(eq(schema.inventoryTransactions.actorUserId, userId))
.returning({ id: schema.inventoryTransactions.id });
if (transactionAnon.length) log.push({ table: getTableName(schema.inventoryTransactions), action: "anonymize", count: transactionAnon.length });
if (transactionAnon.length)
log.push({
table: getTableName(schema.inventoryTransactions),
action: "anonymize",
count: transactionAnon.length,
});
const mealBoxAnon = await db
.update(schema.mealBoxes)
.set({ reservedForUserId: null })
.where(eq(schema.mealBoxes.reservedForUserId, userId))
.returning({ id: schema.mealBoxes.id });
if (mealBoxAnon.length) log.push({ table: getTableName(schema.mealBoxes), action: "anonymize", count: mealBoxAnon.length });
if (mealBoxAnon.length)
log.push({
table: getTableName(schema.mealBoxes),
action: "anonymize",
count: mealBoxAnon.length,
});
const shoppingAnon = await db
.update(schema.shoppingListItems)
.set({ addedByUserId: null })
.where(eq(schema.shoppingListItems.addedByUserId, userId))
.returning({ id: schema.shoppingListItems.id });
if (shoppingAnon.length) log.push({ table: getTableName(schema.shoppingListItems), action: "anonymize", count: shoppingAnon.length });
if (shoppingAnon.length)
log.push({
table: getTableName(schema.shoppingListItems),
action: "anonymize",
count: shoppingAnon.length,
});
}
// ---- 9. Analytics ----
const analyticsDel = await deleteUserAnalyticsEvents(db, userId);
if (analyticsDel > 0) log.push({ table: "analytics_events", action: "delete", count: analyticsDel });
if (analyticsDel > 0)
log.push({ table: "analytics_events", action: "delete", count: analyticsDel });
// ---- 10. Health/preferences/locale (delete) ----
await db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, userId));
await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, userId));
await db
.delete(schema.userLocalePreferences)
.where(eq(schema.userLocalePreferences.userId, userId));
return log;
}
+68 -47
View File
@@ -111,8 +111,7 @@ async function aiScanBlock(
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;
{ p50: string | number | null; p95: string | number | null } | undefined;
const latestErrors = await db.execute(sql`
SELECT properties->>'errorCode' AS code, occurred_at AS tid
@@ -122,7 +121,9 @@ async function aiScanBlock(
ORDER BY occurred_at DESC
LIMIT 5
`);
const latestErrorsRows = (Array.isArray(latestErrors) ? latestErrors : latestErrors.rows) as Array<{
const latestErrorsRows = (
Array.isArray(latestErrors) ? latestErrors : latestErrors.rows
) as Array<{
code: string | null;
tid: string | Date;
}>;
@@ -132,8 +133,10 @@ async function aiScanBlock(
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 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
@@ -142,7 +145,8 @@ async function aiScanBlock(
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 exact24hRow = (Array.isArray(exact24h) ? exact24h[0] : exact24h.rows[0]) as
{ total: number } | undefined;
const exactCostUsd = asNumber(exact24hRow?.total);
let cost24hMicrocents: number | null = null;
@@ -209,8 +213,7 @@ async function conversionWithin(
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; converted: number }
| undefined;
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
@@ -242,8 +245,7 @@ async function householdConversionWithin(
(SELECT count(*) FROM converted) AS converted
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; converted: number }
| undefined;
{ total: number; converted: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.converted) / asNumber(row.total);
}
@@ -269,8 +271,7 @@ async function cohortRetention(db: Database, day: number): Promise<number | null
(SELECT count(*) FROM active) AS active
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { total: number; active: number }
| undefined;
{ total: number; active: number } | undefined;
if (!row || asNumber(row.total) === 0) return null;
return asNumber(row.active) / asNumber(row.total);
}
@@ -305,8 +306,7 @@ async function engagementBlock(db: Database): Promise<OpsEngagementBlock> {
FROM product_analytics_events
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { c24: number; c7: number; opened: number; viewed: number }
| undefined;
{ c24: number; c7: number; opened: number; viewed: number } | undefined;
const tips = await db.execute(sql`
SELECT count(*)::int AS n
@@ -333,8 +333,7 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
FROM subscriptions
`);
const row = (Array.isArray(result) ? result[0] : result.rows[0]) as
| { failed: number; grace: number }
| undefined;
{ failed: number; grace: number } | undefined;
const trials = await db.execute(sql`
SELECT count(*)::int AS n
@@ -342,7 +341,8 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
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 trialsRow = (Array.isArray(trials) ? trials[0] : trials.rows[0]) as
{ n: number } | undefined;
const store = await db.execute(sql`
SELECT
@@ -352,8 +352,7 @@ async function paymentBlock(db: Database): Promise<OpsPaymentBlock> {
WHERE created_at >= now() - interval '7 days'
`);
const storeRow = (Array.isArray(store) ? store[0] : store.rows[0]) as
| { refunds: number; chargebacks: number }
| undefined;
{ refunds: number; chargebacks: number } | undefined;
return {
failed_nu: asNumber(row?.failed),
@@ -547,8 +546,12 @@ async function subscriptionsBlock(db: Database): Promise<OpsSubscriptionsBlock>
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),
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),
@@ -561,17 +564,22 @@ function storeBlock(): OpsStoreBlock {
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 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";
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[] = [
{
@@ -722,22 +730,35 @@ function alarmBlock(budgetUsd: number, dailySpendUsd: number | null | undefined)
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 [
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,
+58 -37
View File
@@ -220,16 +220,21 @@ async function userConversion(
(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;
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`, `)})`;
return sql`(${sql.join(
values.map((v) => sql`${v}`),
sql`, `,
)})`;
}
async function sameDayConversion(
@@ -260,10 +265,12 @@ async function sameDayConversion(
(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;
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);
}
@@ -298,10 +305,12 @@ async function repeatedEventRate(
(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;
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);
}
@@ -332,10 +341,12 @@ async function householdCollaborationRate(
(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;
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);
}
@@ -369,10 +380,12 @@ async function weeklyTrustedMealWeek2(
(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;
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);
}
@@ -391,10 +404,12 @@ async function correctionRate(
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
corrected: number;
total: number;
} | undefined;
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);
}
@@ -413,10 +428,12 @@ async function conflictResolutionRate(
AND occurred_at >= ${startIso}
AND occurred_at < ${endIso}
`);
const row = (Array.isArray(raw) ? raw[0] : raw.rows[0]) as {
resolved: number;
total: number;
} | undefined;
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);
}
@@ -446,10 +463,12 @@ async function cohortRetention(
(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;
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);
}
@@ -481,10 +500,12 @@ async function householdWeekRetention(
(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;
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);
}
+10 -1
View File
@@ -2,7 +2,16 @@
* Product analytics schema (spec §8).
* Pseudonymous events stored in Postgres, separated from AAMOS memory.
*/
import { index, integer, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
+9 -1
View File
@@ -1,4 +1,12 @@
import { boolean, doublePrecision, integer, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
import {
boolean,
doublePrecision,
integer,
pgTable,
text,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { createdAt, updatedAt } from "./_shared.js";
import { inventoryItems } from "./inventory.js";
+3 -4
View File
@@ -24,10 +24,9 @@ export const households = pgTable(
/** Fas 1b: aktiveringsmått (spec §4.2) */
firstScanCompletedAt: timestamp("first_scan_completed_at", { withTimezone: true }),
fifthItemConfirmedAt: timestamp("fifth_item_confirmed_at", { withTimezone: true }),
firstRecipeRecommendationViewedAt: timestamp(
"first_recipe_recommendation_viewed_at",
{ withTimezone: true },
),
firstRecipeRecommendationViewedAt: timestamp("first_recipe_recommendation_viewed_at", {
withTimezone: true,
}),
firstRecipeSavedOrStartedAt: timestamp("first_recipe_saved_or_started_at", {
withTimezone: true,
}),
+6 -2
View File
@@ -121,7 +121,9 @@ export const inventoryConflicts = pgTable(
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
inventoryItemId: uuid("inventory_item_id").references(() => inventoryItems.id, { onDelete: "cascade" }),
inventoryItemId: uuid("inventory_item_id").references(() => inventoryItems.id, {
onDelete: "cascade",
}),
scanJobId: uuid("scan_job_id"),
/** Beskrivande typ: receipt_vs_image, member_vs_member, offline_vs_server, model_disagreement. */
conflictType: text("conflict_type").notNull(),
@@ -137,7 +139,9 @@ export const inventoryConflicts = pgTable(
proposedResolution: jsonb("proposed_resolution").$type<Record<string, unknown>>(),
status: text("status").notNull().default("open"),
/** Användaren som löste konflikten, eller NULL om system-förslag. */
resolvedByUserId: uuid("resolved_by_user_id").references(() => users.id, { onDelete: "set null" }),
resolvedByUserId: uuid("resolved_by_user_id").references(() => users.id, {
onDelete: "set null",
}),
resolution: jsonb("resolution").$type<Record<string, unknown>>(),
/** Modell- och promptversion som användes när konflikten upptäcktes. */
modelVersion: text("model_version"),
+12 -9
View File
@@ -217,14 +217,16 @@ export const cookingSessions = pgTable(
/** Måltidsdatum (UTC) som rester/matlådor knyts till. */
mealDate: date("meal_date"),
/** Vilka meal_boxes som påverkades av sessionen och med hur mycket. */
mealBoxMutations: jsonb("meal_box_mutations").$type<
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
>(),
mealBoxMutations:
jsonb("meal_box_mutations").$type<
Array<{ mealBoxId: string; deltaPortions: number; frozen: boolean }>
>(),
leftoverNote: text("leftover_note"),
/** Ursprungligt FEFO-förslag, sparas för ev. undo/omräkning. */
plannedDeductions: jsonb("planned_deductions").$type<
Array<{ itemId: string; quantity: number; unit: string; name: string }>
>(),
plannedDeductions:
jsonb("planned_deductions").$type<
Array<{ itemId: string; quantity: number; unit: string; name: string }>
>(),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
@@ -283,9 +285,10 @@ export const cookingAssumptionProfiles = pgTable(
/** Hur många observationer profilen bygger på. */
observationCount: integer("observation_count").notNull().default(0),
/** Senaste rådata för felsökning/återspelning. */
lastSessionAnswers: jsonb("last_session_answers").$type<
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
>(),
lastSessionAnswers:
jsonb("last_session_answers").$type<
Array<{ sessionId: string; eaten: number; leftovers: number; date: string }>
>(),
updatedAt: updatedAt(),
},
(t) => [
+13 -5
View File
@@ -2,7 +2,18 @@
* 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 {
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,
@@ -19,10 +30,7 @@ export const releaseGateComparisonEnum = pgEnum(
"release_gate_comparison",
tuple(RELEASE_GATE_COMPARISONS),
);
export const releaseGateStatusEnum = pgEnum(
"release_gate_status",
tuple(RELEASE_GATE_STATUSES),
);
export const releaseGateStatusEnum = pgEnum("release_gate_status", tuple(RELEASE_GATE_STATUSES));
export const releaseGates = pgTable(
"release_gates",
File diff suppressed because it is too large Load Diff