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
@@ -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 });
}
@@ -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 });
}