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