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