2beb23b360
Owner opts in per-list via a new "Allow editing" toggle next to the existing public-link switch. Anonymous writes are scoped to that one list only — the link id is the sole credential, enforced in getShoppingListAccess and the item routes (no session required there now), with an IP rate limit on genuinely anonymous requests. Turning off the public link also revokes editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
85 lines
3.4 KiB
TypeScript
85 lines
3.4 KiB
TypeScript
import { db, shoppingLists, shoppingListMembers, eq } from "@epicure/db";
|
|
import { sendPushNotification } from "@/lib/push";
|
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
|
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
|
|
|
|
// 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,
|
|
// null actorName = an anonymous edit via a public-editable share link — no
|
|
// per-visitor identity exists, so this is rendered as a localized generic
|
|
// label (per recipient's own locale) rather than a real name.
|
|
actorName: string | null,
|
|
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) => {
|
|
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
|
|
|
|
const messages = getMessages(recipient.locale);
|
|
const displayName = actorName ?? messages.publicShoppingList.anonymousEditor;
|
|
const title = messages.notifications.pushTitle.shoppingListUpdate;
|
|
const body =
|
|
event.type === "checked"
|
|
? formatMessage(messages.notifications.detail.shoppingListChecked, {
|
|
name: displayName,
|
|
item: event.itemName ?? "",
|
|
list: event.listName,
|
|
})
|
|
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
|
|
name: displayName,
|
|
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);
|
|
});
|
|
})
|
|
);
|
|
}
|
|
|