From 881c5bb1e36c5aa7727940f753112b58b81b10da Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Mon, 10 Aug 2026 04:35:22 +0700 Subject: [PATCH] fix(worker): S2 proactive tips logic, i18n parity, hardened test DB reset - processProactiveTips now iterates all expiring items sorted by urgency and emits the first candidate with a recipe that clears 7-day dedup. - Add pt-PT proactive-tip templates; remove en-GB duplicate; parity test asserts against shared-types SUPPORTED_LANGUAGE_TAGS. - Serialize DB test setup via turbo dependencies; seed truncates only *_test DBs, guarded by both --test flag and DB name suffix. --- apps/worker/package.json | 2 +- apps/worker/src/processors/proactive-tips.ts | 110 ++++++++++--------- apps/worker/src/templates/proactive-tips.ts | 87 +++++---------- apps/worker/test/proactive-tip-i18n.test.ts | 25 +++++ packages/database/package.json | 2 +- packages/database/src/seed/run.ts | 27 +++++ packages/recipe-generation/package.json | 2 +- packages/shared-types/src/locale.ts | 16 +++ turbo.json | 17 +-- 9 files changed, 163 insertions(+), 125 deletions(-) create mode 100644 apps/worker/test/proactive-tip-i18n.test.ts diff --git a/apps/worker/package.json b/apps/worker/package.json index 956e071..244997f 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": "pnpm --filter=@app/database run db:test-setup && vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests", "eval": "tsx src/eval/run.ts", "eval:scan": "tsx src/eval/scan-eval.ts" }, diff --git a/apps/worker/src/processors/proactive-tips.ts b/apps/worker/src/processors/proactive-tips.ts index 13ae70f..89a85ad 100644 --- a/apps/worker/src/processors/proactive-tips.ts +++ b/apps/worker/src/processors/proactive-tips.ts @@ -36,19 +36,16 @@ export interface ProcessProactiveTipsOptions { nowUtc?: Date; } -function consentGranted( - consents: Array<{ kind: string; status: string }>, - kind: string, -): boolean { +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( +/** Hitta alla utgångsnära varor för hushållet (≤2 dagar kvar), sorterade efter brådska. */ +async function findExpiringItems( ctx: WorkerContext, householdId: string, now: Date, -): Promise { +): Promise { const rows = await ctx.db .select({ item: schema.inventoryItems, @@ -96,7 +93,7 @@ async function findExpiringItem( // Prioritera kortast tid kvar. expiring.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99)); - return expiring[0] ?? null; + return expiring; } /** Hitta ett lämpligt recept som använder ingrediensen och inte lagats nyligen. */ @@ -215,9 +212,7 @@ export async function processProactiveTips( 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); + const households = await ctx.db.select({ id: schema.households.id }).from(schema.households); let created = 0; @@ -227,13 +222,9 @@ export async function processProactiveTips( 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; + // Hämta alla utgångsnära varor sorterade efter brådska. + const items = await findExpiringItems(ctx, household.id, now); + if (items.length === 0) continue; // Välj högst en berättigad mottagare per hushåll per dag. const members = await ctx.db @@ -253,42 +244,63 @@ export async function processProactiveTips( } if (!recipientId) continue; - // Ingen upprepning av samma vara+recept inom 7 dagar. - if (await isDuplicateTip(ctx, recipientId, item.canonicalIngredientId, recipe.recipeId, sevenDaysAgo)) { - continue; - } + // Försök varje kandidat i brådskande ordning tills en kvalificerar. + let emitted = false; + for (const item of items) { + const recipe = await findRecipeForIngredient( + ctx, + household.id, + item.canonicalIngredientId, + now, + ); + if (!recipe) continue; - const locale = await getLocaleContext(ctx, recipientId); - const rendered = renderProactiveTip( - "expiring_ingredient", - locale, - { + // 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; + }); + 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++; + 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, + }); + emitted = true; + created++; + break; + } + + // Om ingen kandidat kvalificerade går vi vidare till nästa hushåll. + if (!emitted) continue; } return created; diff --git a/apps/worker/src/templates/proactive-tips.ts b/apps/worker/src/templates/proactive-tips.ts index 9dd979f..fa938fa 100644 --- a/apps/worker/src/templates/proactive-tips.ts +++ b/apps/worker/src/templates/proactive-tips.ts @@ -22,132 +22,108 @@ export interface RenderedProactiveTip { variables: ProactiveTipTemplateVariables; } -const TEMPLATES: Record< +export 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.", + 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.", + 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.", + "pt-PT": { + title: "Salva {ingredientName}", + body: "O teu {ingredientName} está a ficar mole — aqui está uma receita que normalmente gostas e que o usa.", }, "de-DE": { title: "Rette {ingredientName}", - body: - "Dein {ingredientName} wird weich — hier ist ein Rezept, das du normalerweise magst und das es verwendet.", + 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.", + 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.", + 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.", + 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.", + 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.", + 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.", + 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.", + 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.", + 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?", + 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?", + 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?", + "pt-PT": { + title: "Jantar de domingo?", + body: "Costumas cozinhar {recipeTitle} no {dayName} — queres planear para esta semana?", }, "de-DE": { title: "Sonntagsessen?", - body: - "Ihr kocht {recipeTitle} oft am {dayName} — wollt ihr es diese Woche planen?", + 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 ?", + 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?", + 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?", + 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ń?", + 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?", + 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?", + 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?", + 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?", + body: "Dere lager ofte {recipeTitle} på {dayName} — vil dere planlegge den denne uken?", }, }, }; @@ -163,10 +139,7 @@ function resolveLocale(languageTag: string): string { return match ?? FALLBACK_LOCALE; } -function interpolate( - template: string, - variables: ProactiveTipTemplateVariables, -): string { +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); diff --git a/apps/worker/test/proactive-tip-i18n.test.ts b/apps/worker/test/proactive-tip-i18n.test.ts new file mode 100644 index 0000000..7e64d65 --- /dev/null +++ b/apps/worker/test/proactive-tip-i18n.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { TEMPLATES } from "../src/templates/proactive-tips.js"; +import { SUPPORTED_LANGUAGE_TAGS } from "@app/shared-types"; + +describe("proactive tip i18n parity", () => { + const canonical = new Set(SUPPORTED_LANGUAGE_TAGS); + + for (const [variant, locales] of Object.entries(TEMPLATES)) { + it(`${variant} täcker exakt de kanoniska språken utan dubbletter`, () => { + const keys = Object.keys(locales); + const uniqueKeys = new Set(keys); + + expect(keys).toHaveLength(SUPPORTED_LANGUAGE_TAGS.length); + expect(uniqueKeys.size).toBe(keys.length); + + for (const tag of SUPPORTED_LANGUAGE_TAGS) { + expect(keys).toContain(tag); + } + + for (const key of keys) { + expect(canonical.has(key)).toBe(true); + } + }); + } +}); diff --git a/packages/database/package.json b/packages/database/package.json index 5fccb13..b10fea1 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -12,7 +12,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "pnpm run db:test-setup && vitest run --passWithNoTests", + "test": "vitest run --passWithNoTests", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/migrate.ts", "db:seed": "tsx src/seed/run.ts", diff --git a/packages/database/src/seed/run.ts b/packages/database/src/seed/run.ts index 8a5f245..7b741ef 100644 --- a/packages/database/src/seed/run.ts +++ b/packages/database/src/seed/run.ts @@ -40,6 +40,16 @@ if (!connectionString) { throw new Error("Sätt DATABASE_URL innan seed körs."); } +function assertTestDatabaseName(url: string): void { + const parsed = new URL(url); + const dbName = parsed.pathname.replace(/^\//, ""); + if (!/_test$/.test(dbName)) { + throw new Error( + `[seed] Vägrar nollställa databas "${dbName}". Truncation kräver att databasnamnet slutar på _test.`, + ); + } +} + /** * Näring, allergener och kostnad för recepten beräknas HÄR, deterministiskt, * ur ingredienserna (spec §61.1–2) – aldrig hårdkodade och aldrig från AI. @@ -48,6 +58,23 @@ async function main() { const { db, pool } = createDatabase(connectionString); console.log("[seed] Startar …"); + // Testläge: nollställ allt användardata så att upprepade körningar blir deterministiska. + // Dubbelgrind: (a) --test / SEED_TARGET=test, OCH (b) URL:en pekar på en *_test-databas. + if (isTestTarget) { + assertTestDatabaseName(connectionString!); + console.log("[seed] Nollställer testdatabasen …"); + const tables = await pool.query<{ tablename: string }>( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT LIKE 'pg_%' + AND tablename NOT LIKE 'drizzle_%'`, + ); + for (const { tablename } of tables.rows) { + await pool.query(`TRUNCATE TABLE "${tablename}" CASCADE`); + } + console.log(`[seed] Nollställde ${tables.rows.length} tabeller.`); + } + // 1. Kanoniska ingredienser for (const ing of SEED_INGREDIENTS) { await db diff --git a/packages/recipe-generation/package.json b/packages/recipe-generation/package.json index 6cf2af7..e66eef9 100644 --- a/packages/recipe-generation/package.json +++ b/packages/recipe-generation/package.json @@ -13,7 +13,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "pnpm --filter=@app/database run db:test-setup && vitest run" + "test": "vitest run" }, "dependencies": { "@app/ai-contracts": "workspace:*", diff --git a/packages/shared-types/src/locale.ts b/packages/shared-types/src/locale.ts index 2380ce7..748656f 100644 --- a/packages/shared-types/src/locale.ts +++ b/packages/shared-types/src/locale.ts @@ -31,6 +31,22 @@ export interface LocaleContext { currencyCode: string; } +/** Kanoniska UI-språk (BCP 47) som alla notismallar måste täcka. */ +export const SUPPORTED_LANGUAGE_TAGS = [ + "sv-SE", + "en-US", + "de-DE", + "fr-FR", + "es-ES", + "it-IT", + "pl-PL", + "nl-NL", + "fi-FI", + "da-DK", + "nb-NO", + "pt-PT", +] as const; + export const DEFAULT_LOCALE_PREFERENCES: UserLocalePreferences = { languageTag: "sv-SE", regionCode: "SE", diff --git a/turbo.json b/turbo.json index bf06e91..2dbc1a4 100644 --- a/turbo.json +++ b/turbo.json @@ -17,22 +17,7 @@ "env": ["TEST_DATABASE_URL"] }, "test": { - "dependsOn": ["^typecheck"], - "outputs": [], - "env": ["TEST_DATABASE_URL"] - }, - "@app/api#test": { - "dependsOn": ["@app/database#db:test-setup"], - "outputs": [], - "env": ["TEST_DATABASE_URL"] - }, - "@app/database#test": { - "dependsOn": ["@app/database#db:test-setup"], - "outputs": [], - "env": ["TEST_DATABASE_URL"] - }, - "@app/worker#test": { - "dependsOn": ["@app/database#db:test-setup"], + "dependsOn": ["^typecheck", "@app/database#db:test-setup"], "outputs": [], "env": ["TEST_DATABASE_URL"] },