diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts index d37e741..2600473 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db, shoppingListItems, eq, and } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; +import { notifyShoppingListMembers } from "@/lib/shopping-list-notify"; type Params = { params: Promise<{ id: string; itemId: string }> }; @@ -44,6 +45,23 @@ export async function PUT(req: NextRequest, { params }: Params) { .where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id))); } + // Only checking an item OFF is worth pinging other members about (the + // "someone's shopping right now" coordination signal) — unchecking, + // renames, reordering, and aisle edits are too noisy for a push. + if (data.checked === true) { + const item = await db.query.shoppingListItems.findFirst({ + where: eq(shoppingListItems.id, itemId), + columns: { rawName: true }, + }); + if (item) { + void notifyShoppingListMembers(id, session!.user.id, session!.user.name, { + type: "checked", + itemName: item.rawName, + listName: access.list.name, + }).catch(() => {}); + } + } + return NextResponse.json({ updated: true }); } diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts index ccb87eb..6daf0f5 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts @@ -4,6 +4,7 @@ import { db, shoppingListItems, eq, and } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; import { guessAisle } from "@/lib/grocery-categories"; +import { notifyShoppingListMembers } from "@/lib/shopping-list-notify"; const AddItemsSchema = z.object({ items: z.array(z.object({ @@ -53,6 +54,12 @@ export async function POST(req: NextRequest, { params }: Params) { })) ); + void notifyShoppingListMembers(id, session!.user.id, session!.user.name, { + type: "itemsAdded", + count: parsed.data.items.length, + listName: access.list.name, + }).catch(() => {}); + return NextResponse.json({ ok: true }, { status: 201 }); } diff --git a/apps/web/lib/shopping-list-notify.ts b/apps/web/lib/shopping-list-notify.ts new file mode 100644 index 0000000..41681a9 --- /dev/null +++ b/apps/web/lib/shopping-list-notify.ts @@ -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(); + 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 { + 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); + }); + }) + ); +} + diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e8a6e92..5d32837 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -41,7 +41,9 @@ "detail": { "comment": "{name} commented on your recipe \"{title}\"", "rating": "{name} rated your recipe \"{title}\" {stars} stars", - "leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!" + "leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!", + "shoppingListChecked": "{name} checked off \"{item}\" in \"{list}\"", + "shoppingListItemsAdded": "{name} added {countLabel} to \"{list}\"" }, "pushTitle": { "follow": "New follower", @@ -50,7 +52,8 @@ "reaction": "New reaction", "rating": "New rating", "mention": "You were mentioned", - "leftoverExpiring": "Leftovers expiring soon" + "leftoverExpiring": "Leftovers expiring soon", + "shoppingListUpdate": "Shopping list update" } }, "recipe": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 34e0b46..2c0ab38 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -41,7 +41,9 @@ "detail": { "comment": "{name} a commenté votre recette « {title} »", "rating": "{name} a noté votre recette « {title} » {stars} étoiles", - "leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !" + "leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !", + "shoppingListChecked": "{name} a coché « {item} » dans « {list} »", + "shoppingListItemsAdded": "{name} a ajouté {countLabel} à « {list} »" }, "pushTitle": { "follow": "Nouvel abonné", @@ -50,7 +52,8 @@ "reaction": "Nouvelle réaction", "rating": "Nouvelle note", "mention": "Vous avez été mentionné", - "leftoverExpiring": "Restes bientôt périmés" + "leftoverExpiring": "Restes bientôt périmés", + "shoppingListUpdate": "Mise à jour de la liste de courses" } }, "recipe": {