diff --git a/apps/worker/package.json b/apps/worker/package.json index 244997f..956e071 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js", "build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", + "test": "pnpm --filter=@app/database run db:test-setup && vitest run --passWithNoTests", "eval": "tsx src/eval/run.ts", "eval:scan": "tsx src/eval/scan-eval.ts" }, diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index e1a4210..ce851cb 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -13,6 +13,7 @@ import { processMealBoxReminders, processMemorySync, processOutbox, + processProactiveTips, processRetention, processStoreNotification, processSubscriptionSweep, @@ -82,6 +83,12 @@ const worker = new Worker( return; } + case "SEND_PROACTIVE_TIPS": { + const tips = await processProactiveTips(ctx); + log(`Proaktiva puffar: ${tips}`); + return; + } + case "UPDATE_USER_MEMORY": { const updates = await processMemorySync(ctx); log(`Minnesuppdateringar: ${updates}`); diff --git a/apps/worker/src/processors/maintenance.ts b/apps/worker/src/processors/maintenance.ts index 5d77c9c..fa09bc7 100644 --- a/apps/worker/src/processors/maintenance.ts +++ b/apps/worker/src/processors/maintenance.ts @@ -6,6 +6,8 @@ import { getLocaleContext } from "../locale.js"; import type { WorkerContext } from "../context.js"; import { cancelTimedOutCookingSessions } from "@app/database"; +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). diff --git a/apps/worker/src/processors/proactive-tips.ts b/apps/worker/src/processors/proactive-tips.ts new file mode 100644 index 0000000..13ae70f --- /dev/null +++ b/apps/worker/src/processors/proactive-tips.ts @@ -0,0 +1,295 @@ +import { and, desc, eq, gt, gte, inArray, isNull, lte, sql } from "drizzle-orm"; +import { schema } from "@app/database"; +import { classifyExpiry } from "@app/inventory-engine"; +import type { LocaleContext } from "@app/shared-types"; +import { renderProactiveTip } from "../templates/proactive-tips.js"; +import { getLocaleContext } from "../locale.js"; +import type { WorkerContext } from "../context.js"; + +/** + * S2: Proaktiva puffar (opt-in). Skriver till notifications-tabellen, aldrig push. + * + * Hårda krav: + * 1. Dubbelgrind: personalization-samtycke OCH notifications-samtycke granted. + * 2. Mallbaserad text (12 språk), ingen fri AI-text. + * 3. Led med nyttan; aldrig skam/övervakning. + * 4. Max 1 puff/hushåll/dag; ingen upprepning inom 7 dagar. + * 5. UTC för all datummatte. + */ + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * ONE_DAY_MS; + +interface ExpiringItem { + itemId: string; + canonicalIngredientId: string | null; + displayName: string; + daysLeft: number | null; +} + +interface RecipeMatch { + recipeId: string; + titleSv: string; +} + +export interface ProcessProactiveTipsOptions { + nowUtc?: Date; +} + +function consentGranted( + consents: Array<{ kind: string; status: string }>, + kind: string, +): boolean { + return consents.some((c) => c.kind === kind && c.status === "granted"); +} + +/** Hitta en utgångsnära vara för hushållet (≤2 dagar kvar). */ +async function findExpiringItem( + ctx: WorkerContext, + householdId: string, + now: Date, +): Promise { + 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, householdId), + isNull(schema.inventoryItems.depletedAt), + gt(schema.inventoryItems.quantity, 0), + ), + ); + + const expiring: ExpiringItem[] = []; + for (const r of rows) { + const 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, + }); + if (expiry.status === "expiring" && (expiry.daysLeft ?? 99) <= 2) { + expiring.push({ + itemId: r.item.id, + canonicalIngredientId: r.item.canonicalIngredientId, + displayName: r.item.displayName, + daysLeft: expiry.daysLeft, + }); + } + } + + // Prioritera kortast tid kvar. + expiring.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99)); + return expiring[0] ?? null; +} + +/** Hitta ett lämpligt recept som använder ingrediensen och inte lagats nyligen. */ +async function findRecipeForIngredient( + ctx: WorkerContext, + householdId: string, + canonicalIngredientId: string | null, + now: Date, +): Promise { + if (!canonicalIngredientId) return null; + + const recentCooks = await ctx.db + .select({ recipeId: schema.recipeCooks.recipeId }) + .from(schema.recipeCooks) + .where( + and( + eq(schema.recipeCooks.householdId, householdId), + gte(schema.recipeCooks.cookedAt, new Date(now.getTime() - SEVEN_DAYS_MS)), + ), + ); + const recentlyCookedIds = new Set(recentCooks.map((r) => r.recipeId)); + + const matches = await ctx.db + .select({ + recipeId: schema.recipeIngredients.recipeId, + titleSv: schema.recipes.titleSv, + }) + .from(schema.recipeIngredients) + .innerJoin(schema.recipes, eq(schema.recipeIngredients.recipeId, schema.recipes.id)) + .where( + and( + eq(schema.recipeIngredients.canonicalIngredientId, canonicalIngredientId), + eq(schema.recipes.verificationStatus, "verified"), + ), + ) + .orderBy(desc(schema.recipes.ratingAverage)) + .limit(20); + + for (const m of matches) { + if (!recentlyCookedIds.has(m.recipeId)) { + return { recipeId: m.recipeId, titleSv: m.titleSv }; + } + } + return matches[0] ?? null; +} + +/** Max 1 puff/hushåll/dag. */ +async function householdReceivedTipToday( + ctx: WorkerContext, + householdId: string, + since: Date, +): Promise { + const members = await ctx.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, householdId)); + if (members.length === 0) return false; + + const userIds = members.map((m) => m.userId); + if (userIds.length === 0) return false; + const [recent] = await ctx.db + .select({ id: schema.notifications.id }) + .from(schema.notifications) + .where( + and( + eq(schema.notifications.type, "proactive_tip"), + inArray(schema.notifications.userId, userIds), + gte(schema.notifications.createdAt, since), + ), + ) + .limit(1); + return !!recent; +} + +/** Ingen upprepning av samma vara+recept inom 7 dagar. */ +async function isDuplicateTip( + ctx: WorkerContext, + userId: string, + canonicalIngredientId: string | null, + recipeId: string, + since: Date, +): Promise { + const dedupKey = `${canonicalIngredientId ?? ""}:${recipeId}`; + const rows = await ctx.db + .select({ data: schema.notifications.data }) + .from(schema.notifications) + .where( + and( + eq(schema.notifications.userId, userId), + eq(schema.notifications.type, "proactive_tip"), + gte(schema.notifications.createdAt, since), + ), + ); + return rows.some((r) => { + if (!r.data || typeof r.data !== "object") return false; + const value = (r.data as Record).dedupKey; + return String(value) === dedupKey; + }); +} + +async function getHouseholdConsents( + ctx: WorkerContext, + userId: string, +): Promise> { + return ctx.db + .select({ kind: schema.userConsents.kind, status: schema.userConsents.status }) + .from(schema.userConsents) + .where(eq(schema.userConsents.userId, userId)); +} + +export async function processProactiveTips( + ctx: WorkerContext, + options: ProcessProactiveTipsOptions = {}, +): Promise { + const now = options.nowUtc ?? new Date(); + const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const sevenDaysAgo = new Date(now.getTime() - SEVEN_DAYS_MS); + + const households = await ctx.db + .select({ id: schema.households.id }) + .from(schema.households); + + let created = 0; + + for (const household of households) { + // Frekvens: max 1 puff/hushåll/dag. + if (await householdReceivedTipToday(ctx, household.id, todayStart)) { + continue; + } + + // Hitta en vara att rädda. + const item = await findExpiringItem(ctx, household.id, now); + if (!item) continue; + + // Hitta ett recept som passar. + const recipe = await findRecipeForIngredient(ctx, household.id, item.canonicalIngredientId, now); + if (!recipe) continue; + + // Välj högst en berättigad mottagare per hushåll per dag. + const members = await ctx.db + .select({ userId: schema.householdMembers.userId }) + .from(schema.householdMembers) + .where(eq(schema.householdMembers.householdId, household.id)); + + let recipientId: string | null = null; + for (const member of members) { + const consents = await getHouseholdConsents(ctx, member.userId); + const hasPersonalization = consentGranted(consents, "personalization"); + const hasNotifications = consentGranted(consents, "notifications"); + if (hasPersonalization && hasNotifications) { + recipientId = member.userId; + break; + } + } + if (!recipientId) continue; + + // Ingen upprepning av samma vara+recept inom 7 dagar. + if (await isDuplicateTip(ctx, recipientId, item.canonicalIngredientId, recipe.recipeId, sevenDaysAgo)) { + continue; + } + + const locale = await getLocaleContext(ctx, recipientId); + const rendered = renderProactiveTip( + "expiring_ingredient", + locale, + { + ingredientName: item.displayName, + recipeTitle: recipe.titleSv, + recipeId: recipe.recipeId, + }, + ); + if (!rendered) continue; + + await ctx.db.insert(schema.notifications).values({ + userId: recipientId, + type: "proactive_tip", + titleSv: rendered.title, + bodySv: rendered.body, + data: { + itemId: item.itemId, + recipeId: recipe.recipeId, + ingredientName: item.displayName, + canonicalIngredientId: item.canonicalIngredientId, + dedupKey: `${item.canonicalIngredientId ?? ""}:${recipe.recipeId}`, + }, + templateKey: rendered.templateKey, + variables: rendered.variables, + locale: locale.languageTag, + scheduledFor: now, + createdAt: now, + }); + created++; + } + + return created; +} diff --git a/apps/worker/src/templates/proactive-tips.ts b/apps/worker/src/templates/proactive-tips.ts new file mode 100644 index 0000000..9dd979f --- /dev/null +++ b/apps/worker/src/templates/proactive-tips.ts @@ -0,0 +1,203 @@ +import type { LocaleContext } from "@app/shared-types"; + +/** + * Mallar för proaktiva puffar (S2). All text är mallbaserad; ingen fri AI-text. + * Led alltid med nyttan ("rädda gurkan") och aldrig med övervakning/skam. + */ + +export type ProactiveTipVariant = "expiring_ingredient" | "behavior_mirror"; + +export interface ProactiveTipTemplateVariables { + ingredientName?: string; + recipeTitle?: string; + recipeId?: string; + count?: number; + dayName?: string; +} + +export interface RenderedProactiveTip { + title: string; + body: string; + templateKey: string; + variables: ProactiveTipTemplateVariables; +} + +const TEMPLATES: Record< + ProactiveTipVariant, + Record +> = { + expiring_ingredient: { + "sv-SE": { + title: "Rädda {ingredientName}", + body: + "{ingredientName} börjar bli slapp — här är ett recept du brukar gilla som använder den.", + }, + "en-US": { + title: "Save the {ingredientName}", + body: + "Your {ingredientName} is getting soft — here's a recipe you usually like that uses it.", + }, + "en-GB": { + title: "Save the {ingredientName}", + body: + "Your {ingredientName} is getting soft — here's a recipe you usually like that uses it.", + }, + "de-DE": { + title: "Rette {ingredientName}", + body: + "Dein {ingredientName} wird weich — hier ist ein Rezept, das du normalerweise magst und das es verwendet.", + }, + "fr-FR": { + title: "Sauve {ingredientName}", + body: + "Ton {ingredientName} commence à ramollir — voici une recette que tu aimes habituellement et qui l'utilise.", + }, + "es-ES": { + title: "Salva {ingredientName}", + body: + "Tu {ingredientName} se está poniendo blanda — aquí tienes una receta que sueles gustarte y que la usa.", + }, + "it-IT": { + title: "Salva {ingredientName}", + body: + "Il tuo {ingredientName} sta diventando molle — ecco una ricetta che di solito ti piace e che la usa.", + }, + "pl-PL": { + title: "Uratuj {ingredientName}", + body: + "Twoje {ingredientName} robi się miękkie — oto przepis, który zwykle lubisz i który go używa.", + }, + "nl-NL": { + title: "Red de {ingredientName}", + body: + "Je {ingredientName} wordt zacht — hier is een recept dat je normaal gesproken lekker vindt en dat het gebruikt.", + }, + "fi-FI": { + title: "Pelasta {ingredientName}", + body: + "{ingredientName} alkaa pehmentyä — tässä on resepti, josta yleensä pidät ja jossa sitä käytetään.", + }, + "da-DK": { + title: "Red {ingredientName}", + body: + "Din {ingredientName} begynder at blive slap — her er en opskrift, du plejer at kunne lide, og som bruger den.", + }, + "nb-NO": { + title: "Redd {ingredientName}", + body: + "Din {ingredientName} begynner å bli slapp — her er en oppskrift du pleier å like og som bruker den.", + }, + }, + behavior_mirror: { + "sv-SE": { + title: "Söndagsmiddag?", + body: + "Ni brukar ofta laga {recipeTitle} på {dayName} — vill du planera den här veckan?", + }, + "en-US": { + title: "Sunday dinner?", + body: + "You often cook {recipeTitle} on {dayName} — want to plan it for this week?", + }, + "en-GB": { + title: "Sunday dinner?", + body: + "You often cook {recipeTitle} on {dayName} — want to plan it for this week?", + }, + "de-DE": { + title: "Sonntagsessen?", + body: + "Ihr kocht {recipeTitle} oft am {dayName} — wollt ihr es diese Woche planen?", + }, + "fr-FR": { + title: "Dîner dominical ?", + body: + "Vous cuisinez souvent {recipeTitle} le {dayName} — envie de la planifier cette semaine ?", + }, + "es-ES": { + title: "¿Cena del domingo?", + body: + "Soleis cocinar {recipeTitle} los {dayName} — ¿queréis planearla para esta semana?", + }, + "it-IT": { + title: "Cena della domenica?", + body: + "Cuocete spesso {recipeTitle} di {dayName} — volete pianificarla per questa settimana?", + }, + "pl-PL": { + title: "Kolacja niedzielna?", + body: + "Często gotujesz {recipeTitle} w {dayName} — chcesz zaplanować to na ten tydzień?", + }, + "nl-NL": { + title: "Zondagsdiner?", + body: + "Jullie koken {recipeTitle} vaak op {dayName} — willen jullie het deze week plannen?", + }, + "fi-FI": { + title: "Sunnuntaiillallinen?", + body: + "Teette usein {recipeTitle} {dayName} — haluatteko suunnitella sen tälle viikolle?", + }, + "da-DK": { + title: "Søndagsmiddag?", + body: + "I laver ofte {recipeTitle} om {dayName} — vil I planlægge den denne uge?", + }, + "nb-NO": { + title: "Søndagsmiddag?", + body: + "Dere lager ofte {recipeTitle} på {dayName} — vil dere planlegge den denne uken?", + }, + }, +}; + +const FALLBACK_LOCALE = "sv-SE"; + +const KNOWN_LOCALES = Object.keys(TEMPLATES.expiring_ingredient); + +function resolveLocale(languageTag: string): string { + if (languageTag in TEMPLATES.expiring_ingredient) return languageTag; + const base = languageTag.split("-")[0]; + const match = KNOWN_LOCALES.find((tag) => tag.startsWith(`${base}-`)); + return match ?? FALLBACK_LOCALE; +} + +function interpolate( + template: string, + variables: ProactiveTipTemplateVariables, +): string { + return template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = variables[key as keyof ProactiveTipTemplateVariables]; + return value === undefined ? "" : String(value); + }); +} + +/** Rendera en proaktiv puff från mall. Returnerar null om locale saknas helt. */ +export function renderProactiveTip( + variant: ProactiveTipVariant, + locale: LocaleContext, + variables: ProactiveTipTemplateVariables, +): RenderedProactiveTip | null { + const localeKey = resolveLocale(locale.languageTag); + const template = TEMPLATES[variant][localeKey]; + if (!template) return null; + return { + title: interpolate(template.title, variables), + body: interpolate(template.body, variables), + templateKey: `notification.proactive_tip.${variant}`, + variables, + }; +} + +/** Kontrollera att renderad text inte innehåller förbjuden copy (R3/R7). */ +export function containsForbiddenTipCopy(text: string): boolean { + const forbidden = [ + /\b(borde|should|solltest|devrais|deberías|dovresti|powinieneś|zou moeten|pitäisi|burde|bør)\b.*\b(äta mindre|eat less|minder essen|manger moins|comer menos|mangiare meno|jeść mniej|minder eten|syödä vähemmän|spise mindre|spise mindre)\b/i, + /\b(obalanserad|unbalanced|unausegglichen|déséquilibrée|desequilibrada|squilibrata|niezrównoważona|onevenwichtig|epätasapainoinen|ubalanceret|ubalansert)\b/i, + /\b(dålig|bad|schlecht|mauvaise|mala|cattiva|zła|slecht|huono|dårlig|dårlig)\b.*\b(vana|habit|gewohnheit|habitude|hábito|abitudine|nawyk|gewoonte|tapa|vane|vane)\b/i, + /\b(straff|punish|bestrafen|punir|castigar|punire|karać|straffen|rangaista|straffe|straffe)\b/i, + /\b(skam|shame|scham|honte|vergüenza|vergogna|wstyd|schaamte|häpeä|skam|skam)\b/i, + ]; + return forbidden.some((re) => re.test(text)); +} diff --git a/apps/worker/test/proactive-tips.test.ts b/apps/worker/test/proactive-tips.test.ts new file mode 100644 index 0000000..d8e5cd6 --- /dev/null +++ b/apps/worker/test/proactive-tips.test.ts @@ -0,0 +1,378 @@ +import "./setup-env.js"; +import { describe, it, expect, beforeEach, afterAll } from "vitest"; +import { and, eq, gte, lt } from "drizzle-orm"; +import { createDatabase, closeDatabase, schema } from "@app/database"; +import { processProactiveTips } from "../src/processors/maintenance.js"; +import type { WorkerContext } from "../src/context.js"; + +const FIXED_UTC = new Date(Date.UTC(2026, 7, 10, 8, 0, 0)); // 2026-08-10 08:00 UTC + +describe.sequential("SEND_PROACTIVE_TIPS", () => { + const testDb = createDatabase(process.env.TEST_DATABASE_URL!); + const ctx: WorkerContext = { db: testDb.db } as never; + const emailBase = "proactive-tips-test"; + + async function cleanup() { + const emails = [ + `${emailBase}-owner@example.invalid`, + `${emailBase}-member@example.invalid`, + ]; + const users = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(eq(schema.users.email, emails[0])); + const userIds = users.map((u) => u.id); + for (const userId of userIds) { + await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId)); + await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, userId)); + await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, userId)); + } + const households = await testDb.db + .select({ id: schema.households.id }) + .from(schema.households) + .where(eq(schema.households.name, "Proactive Tips Test")); + for (const h of households) { + await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, h.id)); + await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id)); + await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id)); + } + for (const email of emails) { + await testDb.db.delete(schema.users).where(eq(schema.users.email, email)); + } + // Clean up test recipes/ingredients if exists. + await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000abc")); + await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000def")); + await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000abc")); + await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000def")); + await testDb.db.delete(schema.canonicalIngredients).where(eq(schema.canonicalIngredients.id, "test_cucumber_001")); + await testDb.db.delete(schema.canonicalIngredients).where(eq(schema.canonicalIngredients.id, "test_tomato_001")); + } + + async function setup() { + const [owner] = await testDb.db + .insert(schema.users) + .values({ + email: `${emailBase}-owner@example.invalid`, + passwordHash: "not-used", + displayName: "Owner", + }) + .returning(); + const [member] = await testDb.db + .insert(schema.users) + .values({ + email: `${emailBase}-member@example.invalid`, + passwordHash: "not-used", + displayName: "Member", + }) + .returning(); + + const [household] = await testDb.db + .insert(schema.households) + .values({ name: "Proactive Tips Test", size: 2, locale: "sv-SE", inviteCode: "PROACTIVETEST" }) + .returning(); + + await testDb.db.insert(schema.householdMembers).values([ + { householdId: household!.id, userId: owner!.id, role: "owner" }, + { householdId: household!.id, userId: member!.id, role: "member" }, + ]); + + const [pantry] = await testDb.db + .insert(schema.storageLocations) + .values({ householdId: household!.id, type: "pantry", name: "Skafferi" }) + .returning(); + + await testDb.db.insert(schema.canonicalIngredients).values({ + id: "test_cucumber_001", + nameSv: "Gurka", + nameEn: "Cucumber", + category: "vegetable", + defaultUnit: "COUNT", + allergens: [], + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + densityGPerMl: 0.95, + nutritionPer100: { kcal: 15, proteinG: 0.7, carbsG: 3.6, fatG: 0.1 }, + nutritionProvenance: { source: "test", confidence: "high" }, + shelfLifeGuidance: { pantryDays: 7, fridgeDays: 10 }, + }); + + await testDb.db.insert(schema.recipes).values({ + id: "00000000-0000-0000-0000-000000000abc", + slug: "test-gurkraita", + titleSv: "Gurkraita", + descriptionSv: "Fräsch gurkraita.", + cuisine: "international", + mealTypes: ["dinner"], + tags: ["quick", "vegetarian"], + methods: ["stovetop"], + difficulty: "easy", + sourceType: "own_editorial", + verificationStatus: "verified", + status: "published", + portions: 4, + prepTimeMinutes: 5, + cookTimeMinutes: 0, + totalTimeMinutes: 5, + nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 }, + dna: { + cuisine: "international", + vegetables: ["cucumber"], + flavorProfile: ["fresh"], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 5, + calories: 120, + proteinGrams: 4, + }, + }); + + await testDb.db.insert(schema.recipeIngredients).values({ + recipeId: "00000000-0000-0000-0000-000000000abc", + canonicalIngredientId: "test_cucumber_001", + displayNameSv: "Gurka", + quantity: 1, + unit: "COUNT", + optional: false, + }); + + return { + ownerId: owner!.id, + memberId: member!.id, + householdId: household!.id, + pantryId: pantry!.id, + }; + } + + async function addExpiringCucumber(householdId: string, pantryId: string, bestBefore: string) { + return testDb.db.insert(schema.inventoryItems).values({ + householdId, + storageLocationId: pantryId, + canonicalIngredientId: "test_cucumber_001", + displayName: "Gurka", + quantity: 2, + unit: "COUNT", + bestBeforeDate: bestBefore, + confidence: 1, + verifiedByUser: true, + trustState: "fresh", + source: "manual_search", + }); + } + + async function grantConsents(userId: string) { + await testDb.db.insert(schema.userConsents).values([ + { userId, kind: "personalization", status: "granted" }, + { userId, kind: "notifications", status: "granted" }, + ]); + } + + beforeEach(async () => { + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDatabase(); + }); + + it("skapar en proaktiv puff när båda samtyckena finns och en vara går ut", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); // 1 dag kvar + + const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(1); + + const notif = await testDb.db + .select() + .from(schema.notifications) + .where(eq(schema.notifications.userId, ownerId)); + expect(notif).toHaveLength(1); + expect(notif[0]!.type).toBe("proactive_tip"); + expect(notif[0]!.titleSv).toContain("Gurka"); + expect(notif[0]!.bodySv).toContain("Gurka"); + expect(notif[0]!.templateKey).toBe("notification.proactive_tip.expiring_ingredient"); + }); + + it("skapar INGEN puff utan notifications-samtycke", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await testDb.db.insert(schema.userConsents).values({ + userId: ownerId, + kind: "personalization", + status: "granted", + }); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(0); + }); + + it("skapar INGEN puff utan personalization-samtycke", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await testDb.db.insert(schema.userConsents).values({ + userId: ownerId, + kind: "notifications", + status: "granted", + }); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(0); + }); + + it("stoppas vid revoke av notifications-samtycke", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + // Först skapas puffen. + let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(1); + + // Revoke. + await testDb.db + .update(schema.userConsents) + .set({ status: "revoked" }) + .where(and(eq(schema.userConsents.userId, ownerId), eq(schema.userConsents.kind, "notifications"))); + + // Rensa notisen för att simulera ny dag. + await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, ownerId)); + + count = await processProactiveTips(ctx, { + nowUtc: new Date(FIXED_UTC.getTime() + 24 * 60 * 60 * 1000), + }); + expect(count).toBe(0); + }); + + it("max 1 puff per hushåll per dag", async () => { + const { ownerId, memberId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await grantConsents(memberId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(1); + + const notifs = await testDb.db + .select() + .from(schema.notifications) + .where(eq(schema.notifications.type, "proactive_tip")); + expect(notifs).toHaveLength(1); + }); + + it("ingen upprepning av samma vara+recept inom 7 dagar", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(1); + + // Efter 7 dagar är dagsgränsen passerad, men 7-dagars duplicate-gräns stoppar samma vara+recept. + count = await processProactiveTips(ctx, { + nowUtc: new Date(Date.UTC(2026, 7, 17, 8, 0, 0)), + }); + expect(count).toBe(0); + }); + + it("UTC-gränser: ny dag kl 00:00 UTC tillåter ny puff för ny vara", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + expect(count).toBe(1); + + // Lägg till en andra utgångsnära vara för att testa att dagsgränsen är UTC. + await testDb.db.insert(schema.canonicalIngredients).values({ + id: "test_tomato_001", + nameSv: "Tomat", + nameEn: "Tomato", + category: "vegetable", + defaultUnit: "COUNT", + allergens: [], + containsGluten: false, + containsLactose: false, + isPork: false, + isBeef: false, + isAlcohol: false, + densityGPerMl: 0.6, + nutritionPer100: { kcal: 18, proteinG: 0.9, carbsG: 3.9, fatG: 0.2 }, + nutritionProvenance: { source: "test", confidence: "high" }, + shelfLifeGuidance: { pantryDays: 5, fridgeDays: 8 }, + }); + await testDb.db.insert(schema.recipes).values({ + id: "00000000-0000-0000-0000-000000000def", + slug: "test-tomatsallad", + titleSv: "Tomatsallad", + descriptionSv: "Fräsch tomatsallad.", + cuisine: "international", + mealTypes: ["dinner"], + tags: ["quick", "vegetarian"], + methods: ["stovetop"], + difficulty: "easy", + sourceType: "own_editorial", + verificationStatus: "verified", + status: "published", + portions: 4, + prepTimeMinutes: 5, + cookTimeMinutes: 0, + totalTimeMinutes: 5, + nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 }, + dna: { + cuisine: "international", + vegetables: ["tomato"], + flavorProfile: ["fresh"], + spiceLevel: 0, + method: "stovetop", + timeMinutes: 5, + calories: 120, + proteinGrams: 4, + }, + }); + await testDb.db.insert(schema.recipeIngredients).values({ + recipeId: "00000000-0000-0000-0000-000000000def", + canonicalIngredientId: "test_tomato_001", + displayNameSv: "Tomat", + quantity: 1, + unit: "COUNT", + optional: false, + }); + await testDb.db.insert(schema.inventoryItems).values({ + householdId, + storageLocationId: pantryId, + canonicalIngredientId: "test_tomato_001", + displayName: "Tomat", + quantity: 2, + unit: "COUNT", + bestBeforeDate: "2026-08-11", + confidence: 1, + verifiedByUser: true, + trustState: "fresh", + source: "manual_search", + }); + + count = await processProactiveTips(ctx, { + nowUtc: new Date(Date.UTC(2026, 7, 11, 0, 0, 0)), + }); + expect(count).toBe(1); + }); + + it("texten leder med nyttan och innehåller ingen förbjuden copy", async () => { + const { ownerId, householdId, pantryId } = await setup(); + await grantConsents(ownerId); + await addExpiringCucumber(householdId, pantryId, "2026-08-11"); + + await processProactiveTips(ctx, { nowUtc: FIXED_UTC }); + + const [notif] = await testDb.db + .select() + .from(schema.notifications) + .where(eq(schema.notifications.userId, ownerId)); + expect(notif!.bodySv).toMatch(/slapp|rädda|börjar bli/i); + expect(notif!.bodySv).not.toMatch(/skam|borde äta mindre|obalanserad|dålig vana/i); + }); +}); diff --git a/docs/34-personalisering-implementation.md b/docs/34-personalisering-implementation.md index 7cd24af..5cc6c3e 100644 --- a/docs/34-personalisering-implementation.md +++ b/docs/34-personalisering-implementation.md @@ -294,3 +294,4 @@ Ingen skiva får påbörjas förrän föregående är godkänd. Varje skiva leve | 2026-08-10 | Initial plan för granskning | Sven | | 2026-08-10 | Godkänd med justeringar: R7 välmående, mall-baserade texter, ai_inferred-konfidens, UPDATE_USER_MEMORY-budget, docs/31 incheckad | Sven / Johan | | 2026-08-10 | S1 implementerat: "Vad ska vi äta?" med provenansmallar, samtyckesgrind, personliga delpoäng, integrationstester; commit `0885f5b` | Sven / Johan | +| 2026-08-10 | S2 implementerat: proaktiva puffar (expiring-ingredient) med dubbelgrind, 12-språksmallar, frekvens-/duplicate-gate, UTC-matte, integrationstester; commit TBD | Sven / Johan | diff --git a/infrastructure/migrations/0022_proactive_tip_consents.sql b/infrastructure/migrations/0022_proactive_tip_consents.sql new file mode 100644 index 0000000..50c8e1b --- /dev/null +++ b/infrastructure/migrations/0022_proactive_tip_consents.sql @@ -0,0 +1,3 @@ +-- S2: proaktiva puffar kräver notifications-opt-in och egen notification_type. +ALTER TYPE "public"."consent_kind" ADD VALUE IF NOT EXISTS 'notifications'; +ALTER TYPE "public"."notification_type" ADD VALUE IF NOT EXISTS 'proactive_tip'; diff --git a/infrastructure/migrations/meta/_journal.json b/infrastructure/migrations/meta/_journal.json index 5a45c44..e8df469 100644 --- a/infrastructure/migrations/meta/_journal.json +++ b/infrastructure/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1786144900000, "tag": "0021_gdpr_retention_pseudonyms", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1786148500000, + "tag": "0022_proactive_tip_consents", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 719ffeb..5ffc319 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -503,6 +503,8 @@ export const CONSENT_KINDS = [ "health_integration", "location_weather", "push_notifications", + /** In-app notifications (proactive tips etc.) – separate from push. */ + "notifications", /** Product analytics – legitimate interest with opt-out (spec §8, §33). */ "product_analytics", ] as const; @@ -568,6 +570,7 @@ export const NOTIFICATION_TYPES = [ "week_plan_change", "pantry_forecast", "subscription_status", + "proactive_tip", ] as const; export type NotificationType = (typeof NOTIFICATION_TYPES)[number];