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
+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;