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
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
import { NotificationCategoriesForm } from "@/components/settings/notification-categories-form";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -21,6 +22,16 @@ export default async function NotificationsPage() {
</div>
<PushSubscribeButton />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settingsForm.notificationCategories.title}</h2>
<p className="text-sm text-muted-foreground mt-1">
{m.settingsForm.notificationCategories.description}
</p>
</div>
<NotificationCategoriesForm />
</section>
</div>
);
}
@@ -5,6 +5,7 @@ import { sendEmail, notificationEmailHtml } from "@/lib/email";
import { sendPushNotification } from "@/lib/push";
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
@@ -53,11 +54,14 @@ export async function POST(req: NextRequest) {
const title = messages.notifications.pushTitle.leftoverExpiring;
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
const url = `/recipes/${log.recipe.id}`;
const pushEnabled = await isNotificationCategoryEnabled(log.user.id, "leftoverExpiring");
await Promise.all([
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
}),
pushEnabled
? sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
})
: Promise.resolve(),
log.user.email
? sendEmail({
to: log.user.email,
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { db, userNotificationPrefs, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getNotificationPrefs } from "@/lib/notification-prefs";
import { z } from "zod";
const PutSchema = z.object({
follow: z.boolean().optional(),
comment: z.boolean().optional(),
reply: z.boolean().optional(),
reaction: z.boolean().optional(),
rating: z.boolean().optional(),
mention: z.boolean().optional(),
leftoverExpiring: z.boolean().optional(),
shoppingList: z.boolean().optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const prefs = await getNotificationPrefs(session!.user.id);
return NextResponse.json({ data: prefs });
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const parsed = PutSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const body = parsed.data;
const userId = session!.user.id;
await db
.insert(userNotificationPrefs)
.values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
.onConflictDoUpdate({
target: userNotificationPrefs.userId,
set: { ...body, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import type { NotificationCategory, NotificationPrefs } from "@/lib/notification-prefs";
const CATEGORIES: NotificationCategory[] = [
"follow", "comment", "reply", "reaction", "rating", "mention", "leftoverExpiring", "shoppingList",
];
export function NotificationCategoriesForm() {
const t = useTranslations("settingsForm.notificationCategories");
const t_common = useTranslations("common");
const [prefs, setPrefs] = useState<NotificationPrefs | null>(null);
const [saving, setSaving] = useState<NotificationCategory | null>(null);
useEffect(() => {
fetch("/api/v1/users/me/notification-prefs")
.then((res) => (res.ok ? res.json() : null))
.then((json) => setPrefs(json?.data ?? null))
.catch(() => setPrefs(null));
}, []);
async function toggle(category: NotificationCategory, checked: boolean) {
if (!prefs) return;
const previous = prefs;
setPrefs({ ...prefs, [category]: checked });
setSaving(category);
try {
const res = await fetch("/api/v1/users/me/notification-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [category]: checked }),
});
if (!res.ok) {
setPrefs(previous);
toast.error(t_common("saveFailed"));
}
} catch {
setPrefs(previous);
toast.error(t_common("saveFailed"));
} finally {
setSaving(null);
}
}
if (!prefs) return null;
return (
<div className="space-y-3">
{CATEGORIES.map((category) => (
<div key={category} className="flex items-center justify-between gap-3">
<Label htmlFor={`notif-${category}`} className="cursor-pointer">
{t(category)}
</Label>
<Switch
id={`notif-${category}`}
checked={prefs[category]}
disabled={saving === category}
onCheckedChange={(checked) => { void toggle(category, checked); }}
/>
</div>
))}
</div>
);
}
+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 =
+12
View File
@@ -1068,6 +1068,18 @@
}
},
"settingsForm": {
"notificationCategories": {
"title": "Notification categories",
"description": "Choose which push notifications you want to receive.",
"follow": "New followers",
"comment": "Comments on your recipes",
"reply": "Replies to your comments",
"reaction": "Reactions to your comments",
"rating": "Ratings on your recipes",
"mention": "Mentions in comments",
"leftoverExpiring": "Leftovers expiring soon",
"shoppingList": "Shared shopping list updates"
},
"profile": "Profile",
"changePhoto": "Change photo",
"removePhoto": "Remove photo",
+12
View File
@@ -1056,6 +1056,18 @@
}
},
"settingsForm": {
"notificationCategories": {
"title": "Catégories de notifications",
"description": "Choisissez les notifications push que vous souhaitez recevoir.",
"follow": "Nouveaux abonnés",
"comment": "Commentaires sur vos recettes",
"reply": "Réponses à vos commentaires",
"reaction": "Réactions à vos commentaires",
"rating": "Notes sur vos recettes",
"mention": "Mentions dans les commentaires",
"leftoverExpiring": "Restes bientôt périmés",
"shoppingList": "Mises à jour des listes de courses partagées"
},
"profile": "Profil",
"changePhoto": "Changer la photo",
"removePhoto": "Supprimer la photo",
@@ -0,0 +1,16 @@
CREATE TABLE "user_notification_prefs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"follow" boolean DEFAULT true NOT NULL,
"comment" boolean DEFAULT true NOT NULL,
"reply" boolean DEFAULT true NOT NULL,
"reaction" boolean DEFAULT true NOT NULL,
"rating" boolean DEFAULT true NOT NULL,
"mention" boolean DEFAULT true NOT NULL,
"leftover_expiring" boolean DEFAULT true NOT NULL,
"shopping_list" boolean DEFAULT true NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_notification_prefs_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "user_notification_prefs" ADD CONSTRAINT "user_notification_prefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -239,6 +239,13 @@
"when": 1783864805460,
"tag": "0033_graceful_jetstream",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783875228288,
"tag": "0034_dapper_nocturne",
"breakpoints": true
}
]
}
+18
View File
@@ -145,10 +145,28 @@ export const userNutritionGoals = pgTable("user_nutrition_goals", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const userNotificationPrefs = pgTable("user_notification_prefs", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
follow: boolean("follow").notNull().default(true),
comment: boolean("comment").notNull().default(true),
reply: boolean("reply").notNull().default(true),
reaction: boolean("reaction").notNull().default(true),
rating: boolean("rating").notNull().default(true),
mention: boolean("mention").notNull().default(true),
leftoverExpiring: boolean("leftover_expiring").notNull().default(true),
shoppingList: boolean("shopping_list").notNull().default(true),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
user: one(users, { fields: [pushSubscriptions.userId], references: [users.id] }),
}));
export const userNotificationPrefsRelations = relations(userNotificationPrefs, ({ one }) => ({
user: one(users, { fields: [userNotificationPrefs.userId], references: [users.id] }),
}));
export const userNutritionGoalsRelations = relations(userNutritionGoals, ({ one }) => ({
user: one(users, { fields: [userNutritionGoals.userId], references: [users.id] }),
}));