ci: trigga på master + formatfix inför Gitea Actions
CI / Typecheck, test & build (push) Failing after 2s
CI / Typecheck, test & build (push) Failing after 2s
This commit is contained in:
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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",
|
||||
|
||||
+26236
-28036
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user