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:
@@ -9,7 +9,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean",
|
"build": "tsup src/index.ts --format esm --target node22 --sourcemap --clean",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "pnpm --filter=@app/database run db:test-setup && vitest run --passWithNoTests",
|
||||||
"eval": "tsx src/eval/run.ts",
|
"eval": "tsx src/eval/run.ts",
|
||||||
"eval:scan": "tsx src/eval/scan-eval.ts"
|
"eval:scan": "tsx src/eval/scan-eval.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
processMealBoxReminders,
|
processMealBoxReminders,
|
||||||
processMemorySync,
|
processMemorySync,
|
||||||
processOutbox,
|
processOutbox,
|
||||||
|
processProactiveTips,
|
||||||
processRetention,
|
processRetention,
|
||||||
processStoreNotification,
|
processStoreNotification,
|
||||||
processSubscriptionSweep,
|
processSubscriptionSweep,
|
||||||
@@ -82,6 +83,12 @@ const worker = new Worker(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "SEND_PROACTIVE_TIPS": {
|
||||||
|
const tips = await processProactiveTips(ctx);
|
||||||
|
log(`Proaktiva puffar: ${tips}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case "UPDATE_USER_MEMORY": {
|
case "UPDATE_USER_MEMORY": {
|
||||||
const updates = await processMemorySync(ctx);
|
const updates = await processMemorySync(ctx);
|
||||||
log(`Minnesuppdateringar: ${updates}`);
|
log(`Minnesuppdateringar: ${updates}`);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { getLocaleContext } from "../locale.js";
|
|||||||
import type { WorkerContext } from "../context.js";
|
import type { WorkerContext } from "../context.js";
|
||||||
import { cancelTimedOutCookingSessions } from "@app/database";
|
import { cancelTimedOutCookingSessions } from "@app/database";
|
||||||
|
|
||||||
|
export { processProactiveTips } from "./proactive-tips.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
|
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
|
||||||
* minnessynk (UPDATE_USER_MEMORY) och matlåde-påminnelser (spec §40, §32).
|
* minnessynk (UPDATE_USER_MEMORY) och matlåde-påminnelser (spec §40, §32).
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { and, desc, eq, gt, gte, inArray, isNull, lte, sql } from "drizzle-orm";
|
||||||
|
import { schema } from "@app/database";
|
||||||
|
import { classifyExpiry } from "@app/inventory-engine";
|
||||||
|
import type { LocaleContext } from "@app/shared-types";
|
||||||
|
import { renderProactiveTip } from "../templates/proactive-tips.js";
|
||||||
|
import { getLocaleContext } from "../locale.js";
|
||||||
|
import type { WorkerContext } from "../context.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S2: Proaktiva puffar (opt-in). Skriver till notifications-tabellen, aldrig push.
|
||||||
|
*
|
||||||
|
* Hårda krav:
|
||||||
|
* 1. Dubbelgrind: personalization-samtycke OCH notifications-samtycke granted.
|
||||||
|
* 2. Mallbaserad text (12 språk), ingen fri AI-text.
|
||||||
|
* 3. Led med nyttan; aldrig skam/övervakning.
|
||||||
|
* 4. Max 1 puff/hushåll/dag; ingen upprepning inom 7 dagar.
|
||||||
|
* 5. UTC för all datummatte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const SEVEN_DAYS_MS = 7 * ONE_DAY_MS;
|
||||||
|
|
||||||
|
interface ExpiringItem {
|
||||||
|
itemId: string;
|
||||||
|
canonicalIngredientId: string | null;
|
||||||
|
displayName: string;
|
||||||
|
daysLeft: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeMatch {
|
||||||
|
recipeId: string;
|
||||||
|
titleSv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessProactiveTipsOptions {
|
||||||
|
nowUtc?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
householdId: string,
|
||||||
|
now: Date,
|
||||||
|
): Promise<ExpiringItem | null> {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({
|
||||||
|
item: schema.inventoryItems,
|
||||||
|
locationType: schema.storageLocations.type,
|
||||||
|
shelfLife: schema.canonicalIngredients.shelfLifeGuidance,
|
||||||
|
})
|
||||||
|
.from(schema.inventoryItems)
|
||||||
|
.innerJoin(
|
||||||
|
schema.storageLocations,
|
||||||
|
eq(schema.inventoryItems.storageLocationId, schema.storageLocations.id),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
schema.canonicalIngredients,
|
||||||
|
eq(schema.inventoryItems.canonicalIngredientId, schema.canonicalIngredients.id),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.inventoryItems.householdId, householdId),
|
||||||
|
isNull(schema.inventoryItems.depletedAt),
|
||||||
|
gt(schema.inventoryItems.quantity, 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expiring: ExpiringItem[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const expiry = classifyExpiry({
|
||||||
|
bestBeforeDate: r.item.bestBeforeDate,
|
||||||
|
useByDate: r.item.useByDate,
|
||||||
|
openedAt: r.item.openedAt,
|
||||||
|
frozenAt: r.item.frozenAt,
|
||||||
|
thawedAt: r.item.thawedAt,
|
||||||
|
purchasedAt: r.item.purchasedAt,
|
||||||
|
storageLocationType: r.locationType,
|
||||||
|
shelfLifeGuidance: r.shelfLife,
|
||||||
|
});
|
||||||
|
if (expiry.status === "expiring" && (expiry.daysLeft ?? 99) <= 2) {
|
||||||
|
expiring.push({
|
||||||
|
itemId: r.item.id,
|
||||||
|
canonicalIngredientId: r.item.canonicalIngredientId,
|
||||||
|
displayName: r.item.displayName,
|
||||||
|
daysLeft: expiry.daysLeft,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritera kortast tid kvar.
|
||||||
|
expiring.sort((a, b) => (a.daysLeft ?? 99) - (b.daysLeft ?? 99));
|
||||||
|
return expiring[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hitta ett lämpligt recept som använder ingrediensen och inte lagats nyligen. */
|
||||||
|
async function findRecipeForIngredient(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
householdId: string,
|
||||||
|
canonicalIngredientId: string | null,
|
||||||
|
now: Date,
|
||||||
|
): Promise<RecipeMatch | null> {
|
||||||
|
if (!canonicalIngredientId) return null;
|
||||||
|
|
||||||
|
const recentCooks = await ctx.db
|
||||||
|
.select({ recipeId: schema.recipeCooks.recipeId })
|
||||||
|
.from(schema.recipeCooks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.recipeCooks.householdId, householdId),
|
||||||
|
gte(schema.recipeCooks.cookedAt, new Date(now.getTime() - SEVEN_DAYS_MS)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const recentlyCookedIds = new Set(recentCooks.map((r) => r.recipeId));
|
||||||
|
|
||||||
|
const matches = await ctx.db
|
||||||
|
.select({
|
||||||
|
recipeId: schema.recipeIngredients.recipeId,
|
||||||
|
titleSv: schema.recipes.titleSv,
|
||||||
|
})
|
||||||
|
.from(schema.recipeIngredients)
|
||||||
|
.innerJoin(schema.recipes, eq(schema.recipeIngredients.recipeId, schema.recipes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.recipeIngredients.canonicalIngredientId, canonicalIngredientId),
|
||||||
|
eq(schema.recipes.verificationStatus, "verified"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(schema.recipes.ratingAverage))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
if (!recentlyCookedIds.has(m.recipeId)) {
|
||||||
|
return { recipeId: m.recipeId, titleSv: m.titleSv };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Max 1 puff/hushåll/dag. */
|
||||||
|
async function householdReceivedTipToday(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
householdId: string,
|
||||||
|
since: Date,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const members = await ctx.db
|
||||||
|
.select({ userId: schema.householdMembers.userId })
|
||||||
|
.from(schema.householdMembers)
|
||||||
|
.where(eq(schema.householdMembers.householdId, householdId));
|
||||||
|
if (members.length === 0) return false;
|
||||||
|
|
||||||
|
const userIds = members.map((m) => m.userId);
|
||||||
|
if (userIds.length === 0) return false;
|
||||||
|
const [recent] = await ctx.db
|
||||||
|
.select({ id: schema.notifications.id })
|
||||||
|
.from(schema.notifications)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.notifications.type, "proactive_tip"),
|
||||||
|
inArray(schema.notifications.userId, userIds),
|
||||||
|
gte(schema.notifications.createdAt, since),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return !!recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ingen upprepning av samma vara+recept inom 7 dagar. */
|
||||||
|
async function isDuplicateTip(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
userId: string,
|
||||||
|
canonicalIngredientId: string | null,
|
||||||
|
recipeId: string,
|
||||||
|
since: Date,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const dedupKey = `${canonicalIngredientId ?? ""}:${recipeId}`;
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({ data: schema.notifications.data })
|
||||||
|
.from(schema.notifications)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.notifications.userId, userId),
|
||||||
|
eq(schema.notifications.type, "proactive_tip"),
|
||||||
|
gte(schema.notifications.createdAt, since),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return rows.some((r) => {
|
||||||
|
if (!r.data || typeof r.data !== "object") return false;
|
||||||
|
const value = (r.data as Record<string, unknown>).dedupKey;
|
||||||
|
return String(value) === dedupKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHouseholdConsents(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Array<{ kind: string; status: string }>> {
|
||||||
|
return ctx.db
|
||||||
|
.select({ kind: schema.userConsents.kind, status: schema.userConsents.status })
|
||||||
|
.from(schema.userConsents)
|
||||||
|
.where(eq(schema.userConsents.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processProactiveTips(
|
||||||
|
ctx: WorkerContext,
|
||||||
|
options: ProcessProactiveTipsOptions = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const now = options.nowUtc ?? new Date();
|
||||||
|
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);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const household of households) {
|
||||||
|
// Frekvens: max 1 puff/hushåll/dag.
|
||||||
|
if (await householdReceivedTipToday(ctx, household.id, todayStart)) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Välj högst en berättigad mottagare per hushåll per dag.
|
||||||
|
const members = await ctx.db
|
||||||
|
.select({ userId: schema.householdMembers.userId })
|
||||||
|
.from(schema.householdMembers)
|
||||||
|
.where(eq(schema.householdMembers.householdId, household.id));
|
||||||
|
|
||||||
|
let recipientId: string | null = null;
|
||||||
|
for (const member of members) {
|
||||||
|
const consents = await getHouseholdConsents(ctx, member.userId);
|
||||||
|
const hasPersonalization = consentGranted(consents, "personalization");
|
||||||
|
const hasNotifications = consentGranted(consents, "notifications");
|
||||||
|
if (hasPersonalization && hasNotifications) {
|
||||||
|
recipientId = member.userId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!recipientId) continue;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import "./setup-env.js";
|
||||||
|
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||||
|
import { and, eq, gte, lt } from "drizzle-orm";
|
||||||
|
import { createDatabase, closeDatabase, schema } from "@app/database";
|
||||||
|
import { processProactiveTips } from "../src/processors/maintenance.js";
|
||||||
|
import type { WorkerContext } from "../src/context.js";
|
||||||
|
|
||||||
|
const FIXED_UTC = new Date(Date.UTC(2026, 7, 10, 8, 0, 0)); // 2026-08-10 08:00 UTC
|
||||||
|
|
||||||
|
describe.sequential("SEND_PROACTIVE_TIPS", () => {
|
||||||
|
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
|
||||||
|
const ctx: WorkerContext = { db: testDb.db } as never;
|
||||||
|
const emailBase = "proactive-tips-test";
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const emails = [
|
||||||
|
`${emailBase}-owner@example.invalid`,
|
||||||
|
`${emailBase}-member@example.invalid`,
|
||||||
|
];
|
||||||
|
const users = await testDb.db
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.email, emails[0]));
|
||||||
|
const userIds = users.map((u) => u.id);
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, userId));
|
||||||
|
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, userId));
|
||||||
|
await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, userId));
|
||||||
|
}
|
||||||
|
const households = await testDb.db
|
||||||
|
.select({ id: schema.households.id })
|
||||||
|
.from(schema.households)
|
||||||
|
.where(eq(schema.households.name, "Proactive Tips Test"));
|
||||||
|
for (const h of households) {
|
||||||
|
await testDb.db.delete(schema.inventoryItems).where(eq(schema.inventoryItems.householdId, h.id));
|
||||||
|
await testDb.db.delete(schema.storageLocations).where(eq(schema.storageLocations.householdId, h.id));
|
||||||
|
await testDb.db.delete(schema.households).where(eq(schema.households.id, h.id));
|
||||||
|
}
|
||||||
|
for (const email of emails) {
|
||||||
|
await testDb.db.delete(schema.users).where(eq(schema.users.email, email));
|
||||||
|
}
|
||||||
|
// Clean up test recipes/ingredients if exists.
|
||||||
|
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000abc"));
|
||||||
|
await testDb.db.delete(schema.recipeIngredients).where(eq(schema.recipeIngredients.recipeId, "00000000-0000-0000-0000-000000000def"));
|
||||||
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000abc"));
|
||||||
|
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.id, "00000000-0000-0000-0000-000000000def"));
|
||||||
|
await testDb.db.delete(schema.canonicalIngredients).where(eq(schema.canonicalIngredients.id, "test_cucumber_001"));
|
||||||
|
await testDb.db.delete(schema.canonicalIngredients).where(eq(schema.canonicalIngredients.id, "test_tomato_001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const [owner] = await testDb.db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
email: `${emailBase}-owner@example.invalid`,
|
||||||
|
passwordHash: "not-used",
|
||||||
|
displayName: "Owner",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [member] = await testDb.db
|
||||||
|
.insert(schema.users)
|
||||||
|
.values({
|
||||||
|
email: `${emailBase}-member@example.invalid`,
|
||||||
|
passwordHash: "not-used",
|
||||||
|
displayName: "Member",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [household] = await testDb.db
|
||||||
|
.insert(schema.households)
|
||||||
|
.values({ name: "Proactive Tips Test", size: 2, locale: "sv-SE", inviteCode: "PROACTIVETEST" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await testDb.db.insert(schema.householdMembers).values([
|
||||||
|
{ householdId: household!.id, userId: owner!.id, role: "owner" },
|
||||||
|
{ householdId: household!.id, userId: member!.id, role: "member" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [pantry] = await testDb.db
|
||||||
|
.insert(schema.storageLocations)
|
||||||
|
.values({ householdId: household!.id, type: "pantry", name: "Skafferi" })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await testDb.db.insert(schema.canonicalIngredients).values({
|
||||||
|
id: "test_cucumber_001",
|
||||||
|
nameSv: "Gurka",
|
||||||
|
nameEn: "Cucumber",
|
||||||
|
category: "vegetable",
|
||||||
|
defaultUnit: "COUNT",
|
||||||
|
allergens: [],
|
||||||
|
containsGluten: false,
|
||||||
|
containsLactose: false,
|
||||||
|
isPork: false,
|
||||||
|
isBeef: false,
|
||||||
|
isAlcohol: false,
|
||||||
|
densityGPerMl: 0.95,
|
||||||
|
nutritionPer100: { kcal: 15, proteinG: 0.7, carbsG: 3.6, fatG: 0.1 },
|
||||||
|
nutritionProvenance: { source: "test", confidence: "high" },
|
||||||
|
shelfLifeGuidance: { pantryDays: 7, fridgeDays: 10 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDb.db.insert(schema.recipes).values({
|
||||||
|
id: "00000000-0000-0000-0000-000000000abc",
|
||||||
|
slug: "test-gurkraita",
|
||||||
|
titleSv: "Gurkraita",
|
||||||
|
descriptionSv: "Fräsch gurkraita.",
|
||||||
|
cuisine: "international",
|
||||||
|
mealTypes: ["dinner"],
|
||||||
|
tags: ["quick", "vegetarian"],
|
||||||
|
methods: ["stovetop"],
|
||||||
|
difficulty: "easy",
|
||||||
|
sourceType: "own_editorial",
|
||||||
|
verificationStatus: "verified",
|
||||||
|
status: "published",
|
||||||
|
portions: 4,
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
cookTimeMinutes: 0,
|
||||||
|
totalTimeMinutes: 5,
|
||||||
|
nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 },
|
||||||
|
dna: {
|
||||||
|
cuisine: "international",
|
||||||
|
vegetables: ["cucumber"],
|
||||||
|
flavorProfile: ["fresh"],
|
||||||
|
spiceLevel: 0,
|
||||||
|
method: "stovetop",
|
||||||
|
timeMinutes: 5,
|
||||||
|
calories: 120,
|
||||||
|
proteinGrams: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await testDb.db.insert(schema.recipeIngredients).values({
|
||||||
|
recipeId: "00000000-0000-0000-0000-000000000abc",
|
||||||
|
canonicalIngredientId: "test_cucumber_001",
|
||||||
|
displayNameSv: "Gurka",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "COUNT",
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerId: owner!.id,
|
||||||
|
memberId: member!.id,
|
||||||
|
householdId: household!.id,
|
||||||
|
pantryId: pantry!.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addExpiringCucumber(householdId: string, pantryId: string, bestBefore: string) {
|
||||||
|
return testDb.db.insert(schema.inventoryItems).values({
|
||||||
|
householdId,
|
||||||
|
storageLocationId: pantryId,
|
||||||
|
canonicalIngredientId: "test_cucumber_001",
|
||||||
|
displayName: "Gurka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "COUNT",
|
||||||
|
bestBeforeDate: bestBefore,
|
||||||
|
confidence: 1,
|
||||||
|
verifiedByUser: true,
|
||||||
|
trustState: "fresh",
|
||||||
|
source: "manual_search",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function grantConsents(userId: string) {
|
||||||
|
await testDb.db.insert(schema.userConsents).values([
|
||||||
|
{ userId, kind: "personalization", status: "granted" },
|
||||||
|
{ userId, kind: "notifications", status: "granted" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await closeDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skapar en proaktiv puff när båda samtyckena finns och en vara går ut", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11"); // 1 dag kvar
|
||||||
|
|
||||||
|
const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
const notif = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.notifications)
|
||||||
|
.where(eq(schema.notifications.userId, ownerId));
|
||||||
|
expect(notif).toHaveLength(1);
|
||||||
|
expect(notif[0]!.type).toBe("proactive_tip");
|
||||||
|
expect(notif[0]!.titleSv).toContain("Gurka");
|
||||||
|
expect(notif[0]!.bodySv).toContain("Gurka");
|
||||||
|
expect(notif[0]!.templateKey).toBe("notification.proactive_tip.expiring_ingredient");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skapar INGEN puff utan notifications-samtycke", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await testDb.db.insert(schema.userConsents).values({
|
||||||
|
userId: ownerId,
|
||||||
|
kind: "personalization",
|
||||||
|
status: "granted",
|
||||||
|
});
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skapar INGEN puff utan personalization-samtycke", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await testDb.db.insert(schema.userConsents).values({
|
||||||
|
userId: ownerId,
|
||||||
|
kind: "notifications",
|
||||||
|
status: "granted",
|
||||||
|
});
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stoppas vid revoke av notifications-samtycke", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
// Först skapas puffen.
|
||||||
|
let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
// Revoke.
|
||||||
|
await testDb.db
|
||||||
|
.update(schema.userConsents)
|
||||||
|
.set({ status: "revoked" })
|
||||||
|
.where(and(eq(schema.userConsents.userId, ownerId), eq(schema.userConsents.kind, "notifications")));
|
||||||
|
|
||||||
|
// Rensa notisen för att simulera ny dag.
|
||||||
|
await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, ownerId));
|
||||||
|
|
||||||
|
count = await processProactiveTips(ctx, {
|
||||||
|
nowUtc: new Date(FIXED_UTC.getTime() + 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("max 1 puff per hushåll per dag", async () => {
|
||||||
|
const { ownerId, memberId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await grantConsents(memberId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
const count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
const notifs = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.notifications)
|
||||||
|
.where(eq(schema.notifications.type, "proactive_tip"));
|
||||||
|
expect(notifs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ingen upprepning av samma vara+recept inom 7 dagar", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
// Efter 7 dagar är dagsgränsen passerad, men 7-dagars duplicate-gräns stoppar samma vara+recept.
|
||||||
|
count = await processProactiveTips(ctx, {
|
||||||
|
nowUtc: new Date(Date.UTC(2026, 7, 17, 8, 0, 0)),
|
||||||
|
});
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UTC-gränser: ny dag kl 00:00 UTC tillåter ny puff för ny vara", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
let count = await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
// Lägg till en andra utgångsnära vara för att testa att dagsgränsen är UTC.
|
||||||
|
await testDb.db.insert(schema.canonicalIngredients).values({
|
||||||
|
id: "test_tomato_001",
|
||||||
|
nameSv: "Tomat",
|
||||||
|
nameEn: "Tomato",
|
||||||
|
category: "vegetable",
|
||||||
|
defaultUnit: "COUNT",
|
||||||
|
allergens: [],
|
||||||
|
containsGluten: false,
|
||||||
|
containsLactose: false,
|
||||||
|
isPork: false,
|
||||||
|
isBeef: false,
|
||||||
|
isAlcohol: false,
|
||||||
|
densityGPerMl: 0.6,
|
||||||
|
nutritionPer100: { kcal: 18, proteinG: 0.9, carbsG: 3.9, fatG: 0.2 },
|
||||||
|
nutritionProvenance: { source: "test", confidence: "high" },
|
||||||
|
shelfLifeGuidance: { pantryDays: 5, fridgeDays: 8 },
|
||||||
|
});
|
||||||
|
await testDb.db.insert(schema.recipes).values({
|
||||||
|
id: "00000000-0000-0000-0000-000000000def",
|
||||||
|
slug: "test-tomatsallad",
|
||||||
|
titleSv: "Tomatsallad",
|
||||||
|
descriptionSv: "Fräsch tomatsallad.",
|
||||||
|
cuisine: "international",
|
||||||
|
mealTypes: ["dinner"],
|
||||||
|
tags: ["quick", "vegetarian"],
|
||||||
|
methods: ["stovetop"],
|
||||||
|
difficulty: "easy",
|
||||||
|
sourceType: "own_editorial",
|
||||||
|
verificationStatus: "verified",
|
||||||
|
status: "published",
|
||||||
|
portions: 4,
|
||||||
|
prepTimeMinutes: 5,
|
||||||
|
cookTimeMinutes: 0,
|
||||||
|
totalTimeMinutes: 5,
|
||||||
|
nutritionPerPortion: { kcal: 120, proteinG: 4, carbsG: 8, fatG: 8 },
|
||||||
|
dna: {
|
||||||
|
cuisine: "international",
|
||||||
|
vegetables: ["tomato"],
|
||||||
|
flavorProfile: ["fresh"],
|
||||||
|
spiceLevel: 0,
|
||||||
|
method: "stovetop",
|
||||||
|
timeMinutes: 5,
|
||||||
|
calories: 120,
|
||||||
|
proteinGrams: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testDb.db.insert(schema.recipeIngredients).values({
|
||||||
|
recipeId: "00000000-0000-0000-0000-000000000def",
|
||||||
|
canonicalIngredientId: "test_tomato_001",
|
||||||
|
displayNameSv: "Tomat",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "COUNT",
|
||||||
|
optional: false,
|
||||||
|
});
|
||||||
|
await testDb.db.insert(schema.inventoryItems).values({
|
||||||
|
householdId,
|
||||||
|
storageLocationId: pantryId,
|
||||||
|
canonicalIngredientId: "test_tomato_001",
|
||||||
|
displayName: "Tomat",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "COUNT",
|
||||||
|
bestBeforeDate: "2026-08-11",
|
||||||
|
confidence: 1,
|
||||||
|
verifiedByUser: true,
|
||||||
|
trustState: "fresh",
|
||||||
|
source: "manual_search",
|
||||||
|
});
|
||||||
|
|
||||||
|
count = await processProactiveTips(ctx, {
|
||||||
|
nowUtc: new Date(Date.UTC(2026, 7, 11, 0, 0, 0)),
|
||||||
|
});
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("texten leder med nyttan och innehåller ingen förbjuden copy", async () => {
|
||||||
|
const { ownerId, householdId, pantryId } = await setup();
|
||||||
|
await grantConsents(ownerId);
|
||||||
|
await addExpiringCucumber(householdId, pantryId, "2026-08-11");
|
||||||
|
|
||||||
|
await processProactiveTips(ctx, { nowUtc: FIXED_UTC });
|
||||||
|
|
||||||
|
const [notif] = await testDb.db
|
||||||
|
.select()
|
||||||
|
.from(schema.notifications)
|
||||||
|
.where(eq(schema.notifications.userId, ownerId));
|
||||||
|
expect(notif!.bodySv).toMatch(/slapp|rädda|börjar bli/i);
|
||||||
|
expect(notif!.bodySv).not.toMatch(/skam|borde äta mindre|obalanserad|dålig vana/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -294,3 +294,4 @@ Ingen skiva får påbörjas förrän föregående är godkänd. Varje skiva leve
|
|||||||
| 2026-08-10 | Initial plan för granskning | Sven |
|
| 2026-08-10 | Initial plan för granskning | Sven |
|
||||||
| 2026-08-10 | Godkänd med justeringar: R7 välmående, mall-baserade texter, ai_inferred-konfidens, UPDATE_USER_MEMORY-budget, docs/31 incheckad | Sven / Johan |
|
| 2026-08-10 | Godkänd med justeringar: R7 välmående, mall-baserade texter, ai_inferred-konfidens, UPDATE_USER_MEMORY-budget, docs/31 incheckad | Sven / Johan |
|
||||||
| 2026-08-10 | S1 implementerat: "Vad ska vi äta?" med provenansmallar, samtyckesgrind, personliga delpoäng, integrationstester; commit `0885f5b` | Sven / Johan |
|
| 2026-08-10 | S1 implementerat: "Vad ska vi äta?" med provenansmallar, samtyckesgrind, personliga delpoäng, integrationstester; commit `0885f5b` | Sven / Johan |
|
||||||
|
| 2026-08-10 | S2 implementerat: proaktiva puffar (expiring-ingredient) med dubbelgrind, 12-språksmallar, frekvens-/duplicate-gate, UTC-matte, integrationstester; commit TBD | Sven / Johan |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- S2: proaktiva puffar kräver notifications-opt-in och egen notification_type.
|
||||||
|
ALTER TYPE "public"."consent_kind" ADD VALUE IF NOT EXISTS 'notifications';
|
||||||
|
ALTER TYPE "public"."notification_type" ADD VALUE IF NOT EXISTS 'proactive_tip';
|
||||||
@@ -148,6 +148,13 @@
|
|||||||
"when": 1786144900000,
|
"when": 1786144900000,
|
||||||
"tag": "0021_gdpr_retention_pseudonyms",
|
"tag": "0021_gdpr_retention_pseudonyms",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786148500000,
|
||||||
|
"tag": "0022_proactive_tip_consents",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -503,6 +503,8 @@ export const CONSENT_KINDS = [
|
|||||||
"health_integration",
|
"health_integration",
|
||||||
"location_weather",
|
"location_weather",
|
||||||
"push_notifications",
|
"push_notifications",
|
||||||
|
/** In-app notifications (proactive tips etc.) – separate from push. */
|
||||||
|
"notifications",
|
||||||
/** Product analytics – legitimate interest with opt-out (spec §8, §33). */
|
/** Product analytics – legitimate interest with opt-out (spec §8, §33). */
|
||||||
"product_analytics",
|
"product_analytics",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -568,6 +570,7 @@ export const NOTIFICATION_TYPES = [
|
|||||||
"week_plan_change",
|
"week_plan_change",
|
||||||
"pantry_forecast",
|
"pantry_forecast",
|
||||||
"subscription_status",
|
"subscription_status",
|
||||||
|
"proactive_tip",
|
||||||
] as const;
|
] as const;
|
||||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user