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.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 04:35:22 +07:00
parent 1de1c68485
commit 881c5bb1e3
9 changed files with 163 additions and 125 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean", "build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean",
"typecheck": "tsc --noEmit", "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": "tsx src/eval/run.ts",
"eval:scan": "tsx src/eval/scan-eval.ts" "eval:scan": "tsx src/eval/scan-eval.ts"
}, },
+61 -49
View File
@@ -36,19 +36,16 @@ export interface ProcessProactiveTipsOptions {
nowUtc?: Date; nowUtc?: Date;
} }
function consentGranted( function consentGranted(consents: Array<{ kind: string; status: string }>, kind: string): boolean {
consents: Array<{ kind: string; status: string }>,
kind: string,
): boolean {
return consents.some((c) => c.kind === kind && c.status === "granted"); return consents.some((c) => c.kind === kind && c.status === "granted");
} }
/** Hitta en utgångsnära vara för hushållet (≤2 dagar kvar). */ /** Hitta alla utgångsnära varor för hushållet (≤2 dagar kvar), sorterade efter brådska. */
async function findExpiringItem( async function findExpiringItems(
ctx: WorkerContext, ctx: WorkerContext,
householdId: string, householdId: string,
now: Date, now: Date,
): Promise<ExpiringItem | null> { ): Promise<ExpiringItem[]> {
const rows = await ctx.db const rows = await ctx.db
.select({ .select({
item: schema.inventoryItems, item: schema.inventoryItems,
@@ -96,7 +93,7 @@ async function findExpiringItem(
// Prioritera kortast tid kvar. // Prioritera kortast tid kvar.
expiring.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99)); 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. */ /** 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 todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const sevenDaysAgo = new Date(now.getTime() - SEVEN_DAYS_MS); const sevenDaysAgo = new Date(now.getTime() - SEVEN_DAYS_MS);
const households = await ctx.db const households = await ctx.db.select({ id: schema.households.id }).from(schema.households);
.select({ id: schema.households.id })
.from(schema.households);
let created = 0; let created = 0;
@@ -227,13 +222,9 @@ export async function processProactiveTips(
continue; continue;
} }
// Hitta en vara att rädda. // Hämta alla utgångsnära varor sorterade efter brådska.
const item = await findExpiringItem(ctx, household.id, now); const items = await findExpiringItems(ctx, household.id, now);
if (!item) continue; if (items.length === 0) 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. // Välj högst en berättigad mottagare per hushåll per dag.
const members = await ctx.db const members = await ctx.db
@@ -253,42 +244,63 @@ export async function processProactiveTips(
} }
if (!recipientId) continue; if (!recipientId) continue;
// Ingen upprepning av samma vara+recept inom 7 dagar. // Försök varje kandidat i brådskande ordning tills en kvalificerar.
if (await isDuplicateTip(ctx, recipientId, item.canonicalIngredientId, recipe.recipeId, sevenDaysAgo)) { let emitted = false;
continue; for (const item of items) {
} const recipe = await findRecipeForIngredient(
ctx,
household.id,
item.canonicalIngredientId,
now,
);
if (!recipe) continue;
const locale = await getLocaleContext(ctx, recipientId); // Ingen upprepning av samma vara+recept inom 7 dagar.
const rendered = renderProactiveTip( if (
"expiring_ingredient", await isDuplicateTip(
locale, ctx,
{ recipientId,
item.canonicalIngredientId,
recipe.recipeId,
sevenDaysAgo,
)
) {
continue;
}
const locale = await getLocaleContext(ctx, recipientId);
const rendered = renderProactiveTip("expiring_ingredient", locale, {
ingredientName: item.displayName, ingredientName: item.displayName,
recipeTitle: recipe.titleSv, recipeTitle: recipe.titleSv,
recipeId: recipe.recipeId, recipeId: recipe.recipeId,
}, });
); if (!rendered) continue;
if (!rendered) continue;
await ctx.db.insert(schema.notifications).values({ await ctx.db.insert(schema.notifications).values({
userId: recipientId, userId: recipientId,
type: "proactive_tip", type: "proactive_tip",
titleSv: rendered.title, titleSv: rendered.title,
bodySv: rendered.body, bodySv: rendered.body,
data: { data: {
itemId: item.itemId, itemId: item.itemId,
recipeId: recipe.recipeId, recipeId: recipe.recipeId,
ingredientName: item.displayName, ingredientName: item.displayName,
canonicalIngredientId: item.canonicalIngredientId, canonicalIngredientId: item.canonicalIngredientId,
dedupKey: `${item.canonicalIngredientId ?? ""}:${recipe.recipeId}`, dedupKey: `${item.canonicalIngredientId ?? ""}:${recipe.recipeId}`,
}, },
templateKey: rendered.templateKey, templateKey: rendered.templateKey,
variables: rendered.variables, variables: rendered.variables,
locale: locale.languageTag, locale: locale.languageTag,
scheduledFor: now, scheduledFor: now,
createdAt: now, createdAt: now,
}); });
created++; emitted = true;
created++;
break;
}
// Om ingen kandidat kvalificerade går vi vidare till nästa hushåll.
if (!emitted) continue;
} }
return created; return created;
+30 -57
View File
@@ -22,132 +22,108 @@ export interface RenderedProactiveTip {
variables: ProactiveTipTemplateVariables; variables: ProactiveTipTemplateVariables;
} }
const TEMPLATES: Record< export const TEMPLATES: Record<
ProactiveTipVariant, ProactiveTipVariant,
Record<string, { title: string; body: string }> Record<string, { title: string; body: string }>
> = { > = {
expiring_ingredient: { expiring_ingredient: {
"sv-SE": { "sv-SE": {
title: "Rädda {ingredientName}", title: "Rädda {ingredientName}",
body: body: "{ingredientName} börjar bli slapp — här är ett recept du brukar gilla som använder den.",
"{ingredientName} börjar bli slapp — här är ett recept du brukar gilla som använder den.",
}, },
"en-US": { "en-US": {
title: "Save the {ingredientName}", title: "Save the {ingredientName}",
body: body: "Your {ingredientName} is getting soft — here's a recipe you usually like that uses it.",
"Your {ingredientName} is getting soft — here's a recipe you usually like that uses it.",
}, },
"en-GB": { "pt-PT": {
title: "Save the {ingredientName}", title: "Salva {ingredientName}",
body: body: "O teu {ingredientName} está a ficar mole — aqui está uma receita que normalmente gostas e que o usa.",
"Your {ingredientName} is getting soft — here's a recipe you usually like that uses it.",
}, },
"de-DE": { "de-DE": {
title: "Rette {ingredientName}", title: "Rette {ingredientName}",
body: body: "Dein {ingredientName} wird weich — hier ist ein Rezept, das du normalerweise magst und das es verwendet.",
"Dein {ingredientName} wird weich — hier ist ein Rezept, das du normalerweise magst und das es verwendet.",
}, },
"fr-FR": { "fr-FR": {
title: "Sauve {ingredientName}", title: "Sauve {ingredientName}",
body: body: "Ton {ingredientName} commence à ramollir — voici une recette que tu aimes habituellement et qui l'utilise.",
"Ton {ingredientName} commence à ramollir — voici une recette que tu aimes habituellement et qui l'utilise.",
}, },
"es-ES": { "es-ES": {
title: "Salva {ingredientName}", title: "Salva {ingredientName}",
body: body: "Tu {ingredientName} se está poniendo blanda — aquí tienes una receta que sueles gustarte y que la usa.",
"Tu {ingredientName} se está poniendo blanda — aquí tienes una receta que sueles gustarte y que la usa.",
}, },
"it-IT": { "it-IT": {
title: "Salva {ingredientName}", title: "Salva {ingredientName}",
body: body: "Il tuo {ingredientName} sta diventando molle — ecco una ricetta che di solito ti piace e che la usa.",
"Il tuo {ingredientName} sta diventando molle — ecco una ricetta che di solito ti piace e che la usa.",
}, },
"pl-PL": { "pl-PL": {
title: "Uratuj {ingredientName}", title: "Uratuj {ingredientName}",
body: body: "Twoje {ingredientName} robi się miękkie — oto przepis, który zwykle lubisz i który go używa.",
"Twoje {ingredientName} robi się miękkie — oto przepis, który zwykle lubisz i który go używa.",
}, },
"nl-NL": { "nl-NL": {
title: "Red de {ingredientName}", title: "Red de {ingredientName}",
body: body: "Je {ingredientName} wordt zacht — hier is een recept dat je normaal gesproken lekker vindt en dat het gebruikt.",
"Je {ingredientName} wordt zacht — hier is een recept dat je normaal gesproken lekker vindt en dat het gebruikt.",
}, },
"fi-FI": { "fi-FI": {
title: "Pelasta {ingredientName}", title: "Pelasta {ingredientName}",
body: body: "{ingredientName} alkaa pehmentyä — tässä on resepti, josta yleensä pidät ja jossa sitä käytetään.",
"{ingredientName} alkaa pehmentyä — tässä on resepti, josta yleensä pidät ja jossa sitä käytetään.",
}, },
"da-DK": { "da-DK": {
title: "Red {ingredientName}", title: "Red {ingredientName}",
body: body: "Din {ingredientName} begynder at blive slap — her er en opskrift, du plejer at kunne lide, og som bruger den.",
"Din {ingredientName} begynder at blive slap — her er en opskrift, du plejer at kunne lide, og som bruger den.",
}, },
"nb-NO": { "nb-NO": {
title: "Redd {ingredientName}", title: "Redd {ingredientName}",
body: body: "Din {ingredientName} begynner å bli slapp — her er en oppskrift du pleier å like og som bruker den.",
"Din {ingredientName} begynner å bli slapp — her er en oppskrift du pleier å like og som bruker den.",
}, },
}, },
behavior_mirror: { behavior_mirror: {
"sv-SE": { "sv-SE": {
title: "Söndagsmiddag?", title: "Söndagsmiddag?",
body: body: "Ni brukar ofta laga {recipeTitle} på {dayName} — vill du planera den här veckan?",
"Ni brukar ofta laga {recipeTitle} på {dayName} — vill du planera den här veckan?",
}, },
"en-US": { "en-US": {
title: "Sunday dinner?", title: "Sunday dinner?",
body: body: "You often cook {recipeTitle} on {dayName} — want to plan it for this week?",
"You often cook {recipeTitle} on {dayName} — want to plan it for this week?",
}, },
"en-GB": { "pt-PT": {
title: "Sunday dinner?", title: "Jantar de domingo?",
body: body: "Costumas cozinhar {recipeTitle} no {dayName} — queres planear para esta semana?",
"You often cook {recipeTitle} on {dayName} — want to plan it for this week?",
}, },
"de-DE": { "de-DE": {
title: "Sonntagsessen?", title: "Sonntagsessen?",
body: body: "Ihr kocht {recipeTitle} oft am {dayName} — wollt ihr es diese Woche planen?",
"Ihr kocht {recipeTitle} oft am {dayName} — wollt ihr es diese Woche planen?",
}, },
"fr-FR": { "fr-FR": {
title: "Dîner dominical ?", title: "Dîner dominical ?",
body: body: "Vous cuisinez souvent {recipeTitle} le {dayName} — envie de la planifier cette semaine ?",
"Vous cuisinez souvent {recipeTitle} le {dayName} — envie de la planifier cette semaine ?",
}, },
"es-ES": { "es-ES": {
title: "¿Cena del domingo?", title: "¿Cena del domingo?",
body: body: "Soleis cocinar {recipeTitle} los {dayName} — ¿queréis planearla para esta semana?",
"Soleis cocinar {recipeTitle} los {dayName} — ¿queréis planearla para esta semana?",
}, },
"it-IT": { "it-IT": {
title: "Cena della domenica?", title: "Cena della domenica?",
body: body: "Cuocete spesso {recipeTitle} di {dayName} — volete pianificarla per questa settimana?",
"Cuocete spesso {recipeTitle} di {dayName} — volete pianificarla per questa settimana?",
}, },
"pl-PL": { "pl-PL": {
title: "Kolacja niedzielna?", title: "Kolacja niedzielna?",
body: body: "Często gotujesz {recipeTitle} w {dayName} — chcesz zaplanować to na ten tydzień?",
"Często gotujesz {recipeTitle} w {dayName} — chcesz zaplanować to na ten tydzień?",
}, },
"nl-NL": { "nl-NL": {
title: "Zondagsdiner?", title: "Zondagsdiner?",
body: body: "Jullie koken {recipeTitle} vaak op {dayName} — willen jullie het deze week plannen?",
"Jullie koken {recipeTitle} vaak op {dayName} — willen jullie het deze week plannen?",
}, },
"fi-FI": { "fi-FI": {
title: "Sunnuntaiillallinen?", title: "Sunnuntaiillallinen?",
body: body: "Teette usein {recipeTitle} {dayName} — haluatteko suunnitella sen tälle viikolle?",
"Teette usein {recipeTitle} {dayName} — haluatteko suunnitella sen tälle viikolle?",
}, },
"da-DK": { "da-DK": {
title: "Søndagsmiddag?", title: "Søndagsmiddag?",
body: body: "I laver ofte {recipeTitle} om {dayName} — vil I planlægge den denne uge?",
"I laver ofte {recipeTitle} om {dayName} — vil I planlægge den denne uge?",
}, },
"nb-NO": { "nb-NO": {
title: "Søndagsmiddag?", title: "Søndagsmiddag?",
body: body: "Dere lager ofte {recipeTitle} på {dayName} — vil dere planlegge den denne uken?",
"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; return match ?? FALLBACK_LOCALE;
} }
function interpolate( function interpolate(template: string, variables: ProactiveTipTemplateVariables): string {
template: string,
variables: ProactiveTipTemplateVariables,
): string {
return template.replace(/\{(\w+)\}/g, (_match, key) => { return template.replace(/\{(\w+)\}/g, (_match, key) => {
const value = variables[key as keyof ProactiveTipTemplateVariables]; const value = variables[key as keyof ProactiveTipTemplateVariables];
return value === undefined ? "" : String(value); return value === undefined ? "" : String(value);
@@ -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<string>(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);
}
});
}
});
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "pnpm run db:test-setup && vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/migrate.ts", "db:migrate": "tsx src/migrate.ts",
"db:seed": "tsx src/seed/run.ts", "db:seed": "tsx src/seed/run.ts",
+27
View File
@@ -40,6 +40,16 @@ if (!connectionString) {
throw new Error("Sätt DATABASE_URL innan seed körs."); 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, * Näring, allergener och kostnad för recepten beräknas HÄR, deterministiskt,
* ur ingredienserna (spec §61.12) aldrig hårdkodade och aldrig från AI. * ur ingredienserna (spec §61.12) aldrig hårdkodade och aldrig från AI.
@@ -48,6 +58,23 @@ async function main() {
const { db, pool } = createDatabase(connectionString); const { db, pool } = createDatabase(connectionString);
console.log("[seed] Startar …"); 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 // 1. Kanoniska ingredienser
for (const ing of SEED_INGREDIENTS) { for (const ing of SEED_INGREDIENTS) {
await db await db
+1 -1
View File
@@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "pnpm --filter=@app/database run db:test-setup && vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@app/ai-contracts": "workspace:*", "@app/ai-contracts": "workspace:*",
+16
View File
@@ -31,6 +31,22 @@ export interface LocaleContext {
currencyCode: string; 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 = { export const DEFAULT_LOCALE_PREFERENCES: UserLocalePreferences = {
languageTag: "sv-SE", languageTag: "sv-SE",
regionCode: "SE", regionCode: "SE",
+1 -16
View File
@@ -17,22 +17,7 @@
"env": ["TEST_DATABASE_URL"] "env": ["TEST_DATABASE_URL"]
}, },
"test": { "test": {
"dependsOn": ["^typecheck"], "dependsOn": ["^typecheck", "@app/database#db:test-setup"],
"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"],
"outputs": [], "outputs": [],
"env": ["TEST_DATABASE_URL"] "env": ["TEST_DATABASE_URL"]
}, },