feat(worker): S2 proaktiva puffar (expiring-ingredient)

- Ny notification_type 'proactive_tip' och consent_kind 'notifications'.
- Dubbelgrind: personalization + notifications samtycke måste vara granted.
- Mallbaserade pufftexter i 12 språk; ingen fri AI-text.
- Led med nyttan ('gurkan börjar bli slapp — recept du brukar gilla').
- Max 1 puff/hushåll/dag; ingen upprepning av samma vara+recept inom 7 dagar.
- UTC för all datummatte; createdAt sätts explicit från processorns now.
- Ny worker-jobbtyp SEND_PROACTIVE_TIPS.
- Integrationstester för dubbelgrind, revoke, frekvens, duplicate, UTC.
- Migration 0022 + worker test script kör db:test-setup.
This commit is contained in:
Sven (AAMOS AI)
2026-08-10 03:51:57 +07:00
parent e2cc24eee2
commit f494b1810e
10 changed files with 900 additions and 1 deletions
+203
View File
@@ -0,0 +1,203 @@
import type { LocaleContext } from "@app/shared-types";
/**
* Mallar för proaktiva puffar (S2). All text är mallbaserad; ingen fri AI-text.
* Led alltid med nyttan ("rädda gurkan") och aldrig med övervakning/skam.
*/
export type ProactiveTipVariant = "expiring_ingredient" | "behavior_mirror";
export interface ProactiveTipTemplateVariables {
ingredientName?: string;
recipeTitle?: string;
recipeId?: string;
count?: number;
dayName?: string;
}
export interface RenderedProactiveTip {
title: string;
body: string;
templateKey: string;
variables: ProactiveTipTemplateVariables;
}
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.",
},
"en-US": {
title: "Save the {ingredientName}",
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.",
},
"de-DE": {
title: "Rette {ingredientName}",
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.",
},
"es-ES": {
title: "Salva {ingredientName}",
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.",
},
"pl-PL": {
title: "Uratuj {ingredientName}",
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.",
},
"fi-FI": {
title: "Pelasta {ingredientName}",
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.",
},
"nb-NO": {
title: "Redd {ingredientName}",
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?",
},
"en-US": {
title: "Sunday dinner?",
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?",
},
"de-DE": {
title: "Sonntagsessen?",
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 ?",
},
"es-ES": {
title: "¿Cena del domingo?",
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?",
},
"pl-PL": {
title: "Kolacja niedzielna?",
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?",
},
"fi-FI": {
title: "Sunnuntaiillallinen?",
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?",
},
"nb-NO": {
title: "Søndagsmiddag?",
body:
"Dere lager ofte {recipeTitle} på {dayName} — vil dere planlegge den denne uken?",
},
},
};
const FALLBACK_LOCALE = "sv-SE";
const KNOWN_LOCALES = Object.keys(TEMPLATES.expiring_ingredient);
function resolveLocale(languageTag: string): string {
if (languageTag in TEMPLATES.expiring_ingredient) return languageTag;
const base = languageTag.split("-")[0];
const match = KNOWN_LOCALES.find((tag) => tag.startsWith(`${base}-`));
return match ?? FALLBACK_LOCALE;
}
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);
});
}
/** Rendera en proaktiv puff från mall. Returnerar null om locale saknas helt. */
export function renderProactiveTip(
variant: ProactiveTipVariant,
locale: LocaleContext,
variables: ProactiveTipTemplateVariables,
): RenderedProactiveTip | null {
const localeKey = resolveLocale(locale.languageTag);
const template = TEMPLATES[variant][localeKey];
if (!template) return null;
return {
title: interpolate(template.title, variables),
body: interpolate(template.body, variables),
templateKey: `notification.proactive_tip.${variant}`,
variables,
};
}
/** Kontrollera att renderad text inte innehåller förbjuden copy (R3/R7). */
export function containsForbiddenTipCopy(text: string): boolean {
const forbidden = [
/\b(borde|should|solltest|devrais|deberías|dovresti|powinieneś|zou moeten|pitäisi|burde|bør)\b.*\b(äta mindre|eat less|minder essen|manger moins|comer menos|mangiare meno|jeść mniej|minder eten|syödä vähemmän|spise mindre|spise mindre)\b/i,
/\b(obalanserad|unbalanced|unausegglichen|déséquilibrée|desequilibrada|squilibrata|niezrównoważona|onevenwichtig|epätasapainoinen|ubalanceret|ubalansert)\b/i,
/\b(dålig|bad|schlecht|mauvaise|mala|cattiva|zła|slecht|huono|dårlig|dårlig)\b.*\b(vana|habit|gewohnheit|habitude|hábito|abitudine|nawyk|gewoonte|tapa|vane|vane)\b/i,
/\b(straff|punish|bestrafen|punir|castigar|punire|karać|straffen|rangaista|straffe|straffe)\b/i,
/\b(skam|shame|scham|honte|vergüenza|vergogna|wstyd|schaamte|häpeä|skam|skam)\b/i,
];
return forbidden.some((re) => re.test(text));
}