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
@@ -6,6 +6,8 @@ import { getLocaleContext } from "../locale.js";
import type { WorkerContext } from "../context.js";
import { cancelTimedOutCookingSessions } from "@app/database";
export { processProactiveTips } from "./proactive-tips.js";
/**
* Återkommande underhållsjobb: outbox-publicering, bäst före-notiser,
* 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;
}