Files
Cibello-app/apps/worker/src/processors/maintenance.ts
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

541 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { and, asc, eq, gt, inArray, isNull, lt, lte, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { classifyExpiry, computeTrust } from "@app/inventory-engine";
import { deriveMemoryUpdates } from "@app/memory-client";
import { getLocaleContext } from "../locale.js";
import type { WorkerContext } from "../context.js";
import { cancelTimedOutCookingSessions } from "@app/database";
import { recordAiUsage } from "../lib/ai-usage.js";
/** Pessimistisk kostnadsuppskattning för UPDATE_USER_MEMORY (text-only). */
const COST_ESTIMATE_PER_MEMORY_SYNC_USD = 0.001;
/** Maxkonfidens för ai_inferred-minnen (R1: låg startkonfidens). */
const AI_INFERRED_MAX_CONFIDENCE = 0.7;
export { processProactiveTips } from "./proactive-tips.js";
/**
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
* minnessynk (UPDATE_USER_MEMORY) och matlåde-påminnelser (spec §40, §32).
*/
/** Outbox: markera events publicerade (konsumenter läser via DB/analytics). */
export async function processOutbox(ctx: WorkerContext): Promise<number> {
const pending = await ctx.db
.select()
.from(schema.domainEvents)
.where(isNull(schema.domainEvents.publishedAt))
.orderBy(schema.domainEvents.occurredAt)
.limit(200);
if (pending.length === 0) return 0;
await ctx.db
.update(schema.domainEvents)
.set({ publishedAt: new Date() })
.where(
inArray(
schema.domainEvents.id,
pending.map((e) => e.id),
),
);
return pending.length;
}
/**
* UPDATE_TRUST_STATES (Fas 2 §5.2): uppdatera cache-kolumnen inventory_items.trust_state
* utifrån aktiv decay-profil. Körs periodiskt. Decay påverkar endast förtroende,
* aldrig ätbarhet (mjölkprincipen D-035).
*/
export async function processTrustDecay(ctx: WorkerContext): Promise<number> {
const [profile] = await ctx.db
.select({
halfLifeDays: schema.inventoryDecayProfiles.halfLifeDays,
staleAfterDays: schema.inventoryDecayProfiles.staleAfterDays,
})
.from(schema.inventoryDecayProfiles)
.where(eq(schema.inventoryDecayProfiles.active, true))
.orderBy(schema.inventoryDecayProfiles.createdAt)
.limit(1);
const decayProfile = {
halfLifeDays: profile?.halfLifeDays ?? 7,
staleAfterDays: profile?.staleAfterDays ?? 30,
};
const now = new Date();
const rows = await ctx.db
.select()
.from(schema.inventoryItems)
.where(and(isNull(schema.inventoryItems.depletedAt), gt(schema.inventoryItems.quantity, 0)))
.orderBy(asc(schema.inventoryItems.updatedAt))
.limit(500);
let updated = 0;
for (const item of rows) {
const { state } = computeTrust(
{
confidence: item.confidence,
verifiedByUser: item.verifiedByUser,
lastVerifiedAt: item.lastVerifiedAt,
quantity: item.quantity,
updatedAt: item.updatedAt,
},
now,
decayProfile,
);
if (state !== item.trustState) {
await ctx.db
.update(schema.inventoryItems)
.set({ trustState: state, updatedAt: now })
.where(eq(schema.inventoryItems.id, item.id));
updated++;
}
}
return updated;
}
/** COOKING_SESSION_TIMEOUT (Fas 3 §6 / §20): avbryt STARTED-sessioner äldre än 24 h. */
export async function processCookingSessionTimeout(ctx: WorkerContext): Promise<number> {
const sessions = await cancelTimedOutCookingSessions(ctx.db);
return sessions.length;
}
/** SEND_EXPIRY_NOTIFICATION (spec §54): skapa notiser för varor som snart går ut. */
export async function processExpiryNotifications(ctx: WorkerContext): Promise<number> {
const households = await ctx.db.select({ id: schema.households.id }).from(schema.households);
let created = 0;
for (const household of households) {
const rows = await ctx.db
.select({
item: schema.inventoryItems,
locationType: schema.storageLocations.type,
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
})
.from(schema.inventoryItems)
.innerJoin(
schema.storageLocations,
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
)
.leftJoin(
schema.canonicalIngredients,
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
)
.where(
and(
eq(schema.inventoryItems.householdId, household.id),
isNull(schema.inventoryItems.depletedAt),
gt(schema.inventoryItems.quantity, 0),
),
);
const urgent = rows
.map((r) => ({
item: r.item,
expiry: classifyExpiry({
bestBeforeDate: r.item.bestBeforeDate,
useByDate: r.item.useByDate,
openedAt: r.item.openedAt,
frozenAt: r.item.frozenAt,
thawedAt: r.item.thawedAt,
purchasedAt: r.item.purchasedAt,
storageLocationType: r.locationType,
shelfLifeGuidance: r.shelfLife,
}),
}))
.filter((r) => r.expiry.status === "expiring" && (r.expiry.daysLeft ?? 99) <= 2);
if (urgent.length === 0) continue;
const members = await ctx.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, household.id));
const names = urgent
.slice(0, 3)
.map((u) => u.item.displayName)
.join(", ");
const title =
urgent.length === 1
? "En vara bör användas snart"
: `${urgent.length} varor bör användas snart`;
const body = `${names}${urgent.length > 3 ? " med flera" : ""} går snart ut. Tryck för receptförslag som räddar dem.`;
for (const member of members) {
// Max en expiry-notis per användare och dygn.
const [recent] = await ctx.db
.select({ id: schema.notifications.id })
.from(schema.notifications)
.where(
and(
eq(schema.notifications.userId, member.userId),
eq(schema.notifications.type, "expiry_warning"),
gt(schema.notifications.createdAt, new Date(Date.now() - 20 * 3600_000)),
),
)
.limit(1);
if (recent) continue;
await ctx.db.insert(schema.notifications).values({
userId: member.userId,
type: "expiry_warning",
titleSv: title,
bodySv: body,
data: { itemIds: urgent.map((u) => u.item.id) },
// i18n-spec §26: mall + variabler; texten ovan är renderad sv-cache.
templateKey: "notification.expiry_warning",
variables: { count: urgent.length, names, itemIds: urgent.map((u) => u.item.id) },
locale: "sv-SE",
});
created++;
}
}
return created;
}
/** Matlåde-påminnelser (spec §40). */
export async function processMealBoxReminders(ctx: WorkerContext): Promise<number> {
const soon = new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10);
const boxes = await ctx.db
.select()
.from(schema.mealBoxes)
.where(
and(eq(schema.mealBoxes.status, "available"), lte(schema.mealBoxes.recommendedUseBy, soon)),
);
let created = 0;
for (const box of boxes) {
const members = await ctx.db
.select({ userId: schema.householdMembers.userId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, box.householdId));
for (const member of members) {
if (box.reservedForUserId && box.reservedForUserId !== member.userId) continue;
const [recent] = await ctx.db
.select({ id: schema.notifications.id })
.from(schema.notifications)
.where(
and(
eq(schema.notifications.userId, member.userId),
eq(schema.notifications.type, "meal_box_reminder"),
gt(schema.notifications.createdAt, new Date(Date.now() - 20 * 3600_000)),
),
)
.limit(1);
if (recent) continue;
await ctx.db.insert(schema.notifications).values({
userId: member.userId,
type: "meal_box_reminder",
titleSv: "Matlåda väntar",
bodySv: `${box.titleSv} bör ätas senast ${box.recommendedUseBy}.`,
data: { mealBoxId: box.id },
templateKey: "notification.meal_box_reminder",
variables: { mealBoxId: box.id, title: box.titleSv, useBy: box.recommendedUseBy },
locale: "sv-SE",
});
created++;
}
}
return created;
}
/**
* UPDATE_USER_MEMORY (spec §3233): sammanfatta senaste events till
* minnesförslag via AAMOS. Kör ENDAST med personaliseringssamtycke;
* förslag skrivs till memory_items där användaren äger dem.
*/
export async function processMemorySync(ctx: WorkerContext): Promise<number> {
const dailyBudgetUsd = Number(process.env.GEMINI_DAILY_BUDGET_USD ?? 0);
const users = await ctx.db
.select({ userId: schema.userConsents.userId })
.from(schema.userConsents)
.where(
and(
eq(schema.userConsents.kind, "personalization"),
eq(schema.userConsents.status, "granted"),
),
);
let updates = 0;
for (const { userId } of users) {
const events = await ctx.db
.select({
id: schema.domainEvents.id,
type: schema.domainEvents.type,
occurredAt: schema.domainEvents.occurredAt,
payload: schema.domainEvents.payload,
})
.from(schema.domainEvents)
.where(
and(
eq(schema.domainEvents.userId, userId),
gt(schema.domainEvents.occurredAt, new Date(Date.now() - 7 * 86_400_000)),
inArray(schema.domainEvents.type, [
"RECIPE_COOKED",
"RECIPE_RATED",
"MEAL_LOGGED",
"PRODUCT_DISCARDED",
]),
),
)
.limit(100);
if (events.length < 3) continue;
// BUDGET: spegla skanningen — över budget får inget AAMOS-anrop göras.
if (dailyBudgetUsd > 0 && ctx.budgetStore) {
const currentSpend = await ctx.budgetStore.getDailySpendUsd();
if (currentSpend + COST_ESTIMATE_PER_MEMORY_SYNC_USD > dailyBudgetUsd) {
// eslint-disable-next-line no-console
console.log(`UPDATE_USER_MEMORY: skippar ${userId}: daglig budget förbrukad.`);
continue;
}
}
const existing = await ctx.db
.select({ key: schema.memoryItems.key })
.from(schema.memoryItems)
.where(eq(schema.memoryItems.userId, userId));
const consents = await ctx.db
.select()
.from(schema.userConsents)
.where(eq(schema.userConsents.userId, userId));
const has = (kind: string) => consents.find((c) => c.kind === kind)?.status === "granted";
const localeContext = await getLocaleContext(ctx, userId);
const { proposals, usage } = await deriveMemoryUpdates(ctx.aamos, {
scope: "user",
scopeId: userId,
localeContext,
events: events.map((e) => ({
id: e.id,
type: e.type,
occurredAt: e.occurredAt.toISOString(),
payload: e.payload,
})),
existingMemoryKeys: existing.map((e) => e.key),
consentFlags: {
personalization: true,
anonymizedImprovement: has("anonymized_improvement"),
imageTraining: has("image_training"),
},
});
// Bokför AI-kostnad/tokens så total dagskostnad inkluderar minnes-AI:n.
if (usage) {
await recordAiUsage(ctx.db, userId, {
costUsd: usage.costUsd,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
});
}
const eventIdSet = new Set(events.map((e) => e.id));
for (const proposal of proposals) {
// R1: varje förslag måste vara grundat i faktiska events.
const hasEventSupport =
proposal.sourceEventIds.length > 0 &&
proposal.sourceEventIds.every((id) => eventIdSet.has(id));
if (!hasEventSupport) {
// eslint-disable-next-line no-console
console.log(
`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: saknar event-stöd.`,
);
continue;
}
// R1: ai_inferred ska ha låg konfidens.
if (proposal.origin === "ai_inferred" && proposal.confidence > AI_INFERRED_MAX_CONFIDENCE) {
// eslint-disable-next-line no-console
console.log(
`UPDATE_USER_MEMORY: avvisar ${proposal.key} för ${userId}: ai_inferred med för hög confidence.`,
);
continue;
}
// Skriv aldrig över användarverifierade poster (spec §30: user_stated vinner).
const [current] = await ctx.db
.select()
.from(schema.memoryItems)
.where(and(eq(schema.memoryItems.userId, userId), eq(schema.memoryItems.key, proposal.key)))
.limit(1);
if (current?.verifiedByUser || current?.paused) continue;
if (current) {
await ctx.db
.update(schema.memoryItems)
.set({
summarySv: proposal.summarySv,
value: proposal.value,
origin: proposal.origin,
confidence: proposal.confidence,
expiresAt: proposal.expiresAt ? new Date(proposal.expiresAt) : null,
updatedAt: new Date(),
})
.where(eq(schema.memoryItems.id, current.id));
} else {
await ctx.db.insert(schema.memoryItems).values({
userId,
kind: proposal.kind,
key: proposal.key,
summarySv: proposal.summarySv,
value: proposal.value,
origin: proposal.origin,
confidence: proposal.confidence,
expiresAt: proposal.expiresAt ? new Date(proposal.expiresAt) : null,
});
}
updates++;
}
}
return updates;
}
/** BUILD_TRAINING_SAMPLE (spec §33): bank eligible corrections to an app-owned,
* versioned training dataset. In Skiva 1 this is local storage, not an external
* AAMOS/Gemini runTask call, so the job never throws when AAMOS_MODE=gemini.
*/
export async function processTrainingExport(ctx: WorkerContext): Promise<number> {
const corrections = await ctx.db
.select()
.from(schema.aiCorrections)
.where(isNull(schema.aiCorrections.exportedToTraining))
.limit(100);
const eligible = corrections.filter((c) => {
const snapshot = c.consentSnapshot as Record<string, string>;
return snapshot.anonymized_improvement === "granted";
});
if (eligible.length === 0) return 0;
const version = "v1";
const batchId = `cibello-local-${version}-${Date.now()}`;
const exportedAt = new Date();
for (const correction of eligible) {
const userCorrection = correction.userCorrection as Record<string, unknown>;
await ctx.db.insert(schema.aiTrainingBank).values({
correctionId: correction.id,
scanJobId: correction.scanJobId,
version,
taskType: correction.taskType,
imageS3Key: correction.imageS3Key,
proposal: (correction.proposal ?? {}) as Record<string, unknown>,
action: String(userCorrection.action ?? "unknown"),
corrected: (userCorrection.corrected ?? null) as Record<string, unknown> | null,
modelVersion: correction.modelVersion,
promptVersion: correction.promptVersion,
consentSnapshot: correction.consentSnapshot,
exportedAt,
});
await ctx.db
.update(schema.aiCorrections)
.set({ exportedToTraining: exportedAt, trainingBatchId: batchId })
.where(eq(schema.aiCorrections.id, correction.id));
}
return eligible.length;
}
/** VERIFY_SUBSCRIPTION: flagga prenumerationer som passerat expiry. */
export async function processSubscriptionSweep(ctx: WorkerContext): Promise<number> {
const result = await ctx.db
.update(schema.subscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(
and(
inArray(schema.subscriptions.status, ["active", "in_grace", "trial"]),
sql`${schema.subscriptions.expiresAt} IS NOT NULL AND ${schema.subscriptions.expiresAt} < now()`,
sql`(${schema.subscriptions.gracePeriodExpiresAt} IS NULL OR ${schema.subscriptions.gracePeriodExpiresAt} < now())`,
),
)
.returning({ id: schema.subscriptions.id });
return result.length;
}
/** PROCESS_STORE_NOTIFICATION (spec §47): tolka och applicera store-notiser. */
export async function processStoreNotification(
ctx: WorkerContext,
notificationId: string,
): Promise<void> {
const [notification] = await ctx.db
.select()
.from(schema.storeNotifications)
.where(eq(schema.storeNotifications.id, notificationId))
.limit(1);
if (!notification || notification.processed) return;
// Produktionsimplementation (fas 7): verifiera JWS (Apple) / Pub/Sub-token
// (Google), slå upp originalTransactionId och uppdatera subscriptions.
// Tills butiksnycklar finns markeras notisen som mottagen men overifierad.
await ctx.db
.update(schema.storeNotifications)
.set({
processed: true,
processedAt: new Date(),
error: notification.signatureVerified
? null
: "Signaturverifiering väntar på butiksnycklar (se docs/subscriptions.md).",
})
.where(eq(schema.storeNotifications.id, notificationId));
}
/**
* Dataretention (GDPR, spec §56; hardening-checklistans automatiseringskrav).
* Körs dagligen. Konservativa fönster rådata som användaren äger rörs aldrig,
* endast förbrukade säkerhetstokens, gamla jobb och lästa notiser.
*/
export async function processRetention(ctx: WorkerContext): Promise<Record<string, number>> {
const now = Date.now();
const days = (n: number) => new Date(now - n * 86_400_000);
const removed: Record<string, number> = {};
// Förbrukade/utgångna säkerhetstokens äldre än 7 dagar.
removed.passwordResetTokens = (
await ctx.db
.delete(schema.passwordResetTokens)
.where(
sql`(${schema.passwordResetTokens.usedAt} IS NOT NULL OR ${schema.passwordResetTokens.expiresAt} < now()) AND ${schema.passwordResetTokens.createdAt} < ${days(7)}`,
)
.returning({ id: schema.passwordResetTokens.id })
).length;
removed.emailVerificationTokens = (
await ctx.db
.delete(schema.emailVerificationTokens)
.where(
sql`(${schema.emailVerificationTokens.usedAt} IS NOT NULL OR ${schema.emailVerificationTokens.expiresAt} < now()) AND ${schema.emailVerificationTokens.createdAt} < ${days(7)}`,
)
.returning({ id: schema.emailVerificationTokens.id })
).length;
// Återkallade/utgångna refresh-tokens äldre än 30 dagar.
removed.refreshTokens = (
await ctx.db
.delete(schema.refreshTokens)
.where(
sql`(${schema.refreshTokens.revokedAt} IS NOT NULL OR ${schema.refreshTokens.expiresAt} < now()) AND ${schema.refreshTokens.createdAt} < ${days(30)}`,
)
.returning({ id: schema.refreshTokens.id })
).length;
// Skanningsjobb äldre än 90 dagar (spec §53: scans/ 90 dagar).
removed.scanJobs = (
await ctx.db
.delete(schema.scanJobs)
.where(sql`${schema.scanJobs.createdAt} < ${days(90)}`)
.returning({ id: schema.scanJobs.id })
).length;
// Notiser äldre än 90 dagar.
removed.notifications = (
await ctx.db
.delete(schema.notifications)
.where(sql`${schema.notifications.createdAt} < ${days(90)}`)
.returning({ id: schema.notifications.id })
).length;
return removed;
}