feat: push notification when a shared shopping list is updated

Shared shopping lists (owner + collaborator-invite members) had no
real-time coordination signal — no way to know someone else just checked
off milk or added items while you're mid-aisle. Notifies every other
member (excluding the actor) via push when an item is checked off or
items are added; no-op for solo unshared lists.

Deliberately skips the notifications table/notification-center (same
pattern as the existing leftover-expiry cron) — this is a lightweight
best-effort ping, not a persistent event, so it avoids a notification_type
enum migration and a new FK column for something transient.

Verified locally: added a real member to a shared list, registered a
push subscription, confirmed the check-off request's added latency
(945ms vs ~20ms baseline) shows the actual webpush call fired against
the recipient — individual subscription failures are swallowed by design
(Promise.allSettled in the existing sendPushNotification, unchanged);
confirmed a solo list stays fast (no recipients to notify, true no-op).
This commit is contained in:
Arnaud
2026-07-12 18:36:20 +02:00
parent 9566e19cd0
commit aa6bd03c5e
5 changed files with 112 additions and 4 deletions
+77
View File
@@ -0,0 +1,77 @@
import { db, shoppingLists, shoppingListMembers, eq } from "@epicure/db";
import { sendPushNotification } from "@/lib/push";
import { getMessages, formatMessage } from "@/lib/i18n/server";
// 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) => {
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);
});
})
);
}