feat: granular per-category push notification settings

New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
This commit is contained in:
Arnaud
2026-07-12 19:07:32 +02:00
parent e78c959c49
commit 4d5269aced
13 changed files with 5037 additions and 6 deletions
+27
View File
@@ -0,0 +1,27 @@
import { db, userNotificationPrefs, eq } from "@epicure/db";
export type NotificationCategory =
| "follow" | "comment" | "reply" | "reaction" | "rating" | "mention"
| "leftoverExpiring" | "shoppingList";
export type NotificationPrefs = Record<NotificationCategory, boolean>;
const DEFAULT_PREFS: NotificationPrefs = {
follow: true, comment: true, reply: true, reaction: true, rating: true, mention: true,
leftoverExpiring: true, shoppingList: true,
};
export async function getNotificationPrefs(userId: string): Promise<NotificationPrefs> {
const row = await db.query.userNotificationPrefs.findFirst({ where: eq(userNotificationPrefs.userId, userId) });
if (!row) return { ...DEFAULT_PREFS };
return {
follow: row.follow, comment: row.comment, reply: row.reply, reaction: row.reaction,
rating: row.rating, mention: row.mention, leftoverExpiring: row.leftoverExpiring, shoppingList: row.shoppingList,
};
}
/** No row yet means every category defaults to on — same default the DB columns encode. */
export async function isNotificationCategoryEnabled(userId: string, category: NotificationCategory): Promise<boolean> {
const prefs = await getNotificationPrefs(userId);
return prefs[category];
}
+6 -3
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
import { sendPushNotification } from "./push";
import { sendEmail, notificationEmailHtml } from "./email";
import { getMessages, formatMessage } from "./i18n/server";
import { isNotificationCategoryEnabled } from "./notification-prefs";
type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
@@ -75,9 +76,11 @@ async function dispatchAlerts(opts: CreateNotificationOpts): Promise<void> {
? (actor.username ? `/u/${actor.username}` : "/")
: opts.recipeId ? `/recipes/${opts.recipeId}` : "/";
void sendPushNotification(opts.userId, { title, body, url }).catch((err) => {
console.error("[notifications] push failed", err);
});
if (await isNotificationCategoryEnabled(opts.userId, opts.type)) {
void sendPushNotification(opts.userId, { title, body, url }).catch((err) => {
console.error("[notifications] push failed", err);
});
}
if (recipient.email) {
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
+3
View File
@@ -1,6 +1,7 @@
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.
@@ -53,6 +54,8 @@ export async function notifyShoppingListMembers(
await Promise.all(
recipients.map(async (recipient) => {
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
const messages = getMessages(recipient.locale);
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =