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:
@@ -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);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user