Files
Epicure/apps/web/lib/shopping-list-notify.ts
Arnaud 2beb23b360 feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:53:52 +02:00

85 lines
3.4 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,
// null actorName = an anonymous edit via a public-editable share link — no
// per-visitor identity exists, so this is rendered as a localized generic
// label (per recipient's own locale) rather than a real name.
actorName: string | null,
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 displayName = actorName ?? messages.publicShoppingList.anonymousEditor;
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =
event.type === "checked"
? formatMessage(messages.notifications.detail.shoppingListChecked, {
name: displayName,
item: event.itemName ?? "",
list: event.listName,
})
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
name: displayName,
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);
});
})
);
}