Files
Epicure/apps/web/lib/shopping-list-notify.ts
T
Arnaud 4d5269aced feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
2026-07-12 19:07:32 +02:00

81 lines
3.1 KiB
TypeScript

import { db, shoppingLists, shoppingListMembers, eq } from "@epicure/db";
import { sendPushNotification } from "@/lib/push";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
// formatMessage() is a plain {key} interpolator, not ICU-plural-aware, so
// pluralization has to be resolved to a plain string before it's passed in.
function itemsCountLabel(count: number, locale: string | null | undefined): string {
if (locale === "fr") return count === 1 ? "1 article" : `${count} articles`;
return count === 1 ? "1 item" : `${count} items`;
}
/** Other people with access to a shared shopping list — owner + members, excluding the actor. */
async function getOtherRecipients(listId: string, actorId: string) {
const list = await db.query.shoppingLists.findFirst({
where: eq(shoppingLists.id, listId),
columns: { userId: true },
});
const members = await db.query.shoppingListMembers.findMany({
where: eq(shoppingListMembers.listId, listId),
columns: { userId: true },
});
const recipientIds = new Set<string>();
if (list?.userId) recipientIds.add(list.userId);
for (const m of members) recipientIds.add(m.userId);
recipientIds.delete(actorId);
if (recipientIds.size === 0) return [];
return db.query.users.findMany({
where: (t, { inArray }) => inArray(t.id, [...recipientIds]),
columns: { id: true, locale: true },
});
}
/**
* Push-notifies every other member of a shared shopping list about an item
* change. No-op for solo (unshared) lists — getOtherRecipients returns empty.
* Deliberately skips the `notifications` table (no in-app entry) since this
* is meant as a lightweight real-time "someone's shopping too" ping, same
* pattern as the leftover-expiry cron — not a persistent notification-center
* event.
*/
export async function notifyShoppingListMembers(
listId: string,
actorId: string,
actorName: string,
event: { type: "checked" | "itemsAdded"; itemName?: string; count?: number; listName: string }
): Promise<void> {
const recipients = await getOtherRecipients(listId, actorId);
if (recipients.length === 0) return;
const url = `/shopping-lists/${listId}`;
await Promise.all(
recipients.map(async (recipient) => {
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
const messages = getMessages(recipient.locale);
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =
event.type === "checked"
? formatMessage(messages.notifications.detail.shoppingListChecked, {
name: actorName,
item: event.itemName ?? "",
list: event.listName,
})
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
name: actorName,
countLabel: itemsCountLabel(event.count ?? 1, recipient.locale),
list: event.listName,
});
await sendPushNotification(recipient.id, { title, body, url }).catch((err) => {
console.error("[shopping-list-notify] push failed", err);
});
})
);
}