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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user