aa6bd03c5e
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).
78 lines
3.0 KiB
TypeScript
78 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|