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",
"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"
},
+61 -49
View File
@@ -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<ExpiringItem | null> {
): Promise<ExpiringItem[]> {
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;
+30 -57
View File
@@ -22,132 +22,108 @@ export interface RenderedProactiveTip {
variables: ProactiveTipTemplateVariables;
}
const TEMPLATES: Record<
export const TEMPLATES: Record<
ProactiveTipVariant,
Record<string, { title: string; body: string }>
> = {
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);
@@ -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);
}
});
}
});