refactor(notifications): collapse 6 per-workflow toggles into 2 categories

Settings → Bildirimler had grown a 6-row list (welcome / trial-ending /
referral / referral-qualified / referral-reward / win-back) that read like
an internal cron schedule rather than a user choice. Users care about
e-mail vs mobile, not which Novu trigger fires the day-3 nudge.

Replaces the per-workflow UI with two switches:

  • E-posta bildirimleri  — bundles all six marketing/lifecycle workflows
                             above, off = mute all
  • Mobil bildirim         — placeholder for the not-yet-shipped mobile
                             app push channel; the preference is stored
                             so it Just Works when push ships

Auth + payment mail remain unaffected — the server-side OPTIONAL_WORKFLOWS
filter is still the canonical opt-out gate.

API
---
Same path (`/api/email/preferences`), category-shaped payload:

  GET  → `[{category, label, description, optedOut}, …]`  (two rows)
  POST → body `{category, optedOut}`                       (toggles every
                                                            workflow in
                                                            the bundle)

UnsubscribeController is untouched — one-click List-Unsubscribe URLs in
mail still address a single workflow (we don't want clicking the welcome-
mail unsub link to also kill the trial-ending nudge a week later).

Service
-------
New `NOTIFICATION_CATEGORIES` const + `getCategoryState()` /
`setCategoryState()` on EmailPreferencesService. `mobile_push` added to
OPTIONAL_WORKFLOWS so the same row-presence guard works for it.

UI
--
NotificationsCard renders two rows (or two skeletons) — keys are stable
so the skeletons match the final layout. Category copy comes from the
API; static FALLBACK_CATEGORY_COPY avoids a flash of untitled rows
before GET resolves.

PostHog events renamed from `email_workflow_opted_in/out` to
`notifications_category_opted_in/out` since the per-workflow event was
never going to be useful.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude (notifications categorize)
2026-06-04 23:45:35 +03:00
parent e979116738
commit aaa96d82e3
3 changed files with 183 additions and 96 deletions

View File

@@ -2,22 +2,27 @@ import { BadRequestException, Body, Controller, Get, Logger, Post } from "@nestj
import { CurrentUser } from "../common/decorators/current-user.decorator";
import {
EmailPreferencesService,
OPTIONAL_WORKFLOWS,
NOTIFICATION_CATEGORIES,
isNotificationCategory,
} from "./email-preferences.service";
/**
* Authenticated self-service preferences endpoint — paired with
* UnsubscribeController which handles the unauthenticated one-click flow.
*
* GET /api/email/preferences — current state (all optional
* workflows, with `optedOut: bool`).
* POST /api/email/preferences — body `{workflow, optedOut}`;
* true → insert opt-out row,
* false → delete it.
* GET /api/email/preferences — returns the two UI categories
* (`email_marketing`, `mobile_push`) with
* current opt-out boolean and the
* static `label` + `description`.
* POST /api/email/preferences — body `{category, optedOut}`; toggles every
* underlying workflow at once.
*
* Backs the `/dashboard/settings?tab=notifications` UI. Auth and payment
* workflows are deliberately not exposed: they're transactional and the
* service-level `OPTIONAL_WORKFLOWS` set is the single source of truth.
* The endpoint is category-shaped (not per-workflow) because the dashboard UI
* only exposes two switches. UnsubscribeController is still per-workflow —
* one-click List-Unsubscribe URLs in mail can only mute the workflow that
* produced the click (we don't want clicking the welcome-mail unsub link to
* also kill the trial-ending nudge a week later). mailAudit.md §9.3 #14
* follow-on (category-rework 2026-06-05).
*/
@Controller("email/preferences")
export class EmailPreferencesController {
@@ -26,42 +31,40 @@ export class EmailPreferencesController {
constructor(private readonly preferences: EmailPreferencesService) {}
/**
* Returns the per-workflow opt-out state for the calling user. Always
* includes every optional workflow — caller renders one row per — so a
* missing DB row is just `{optedOut: false}`.
* Returns one row per UI category — both keys are always present, so the
* caller can render the switches without a guard.
*/
@Get()
async list(
@CurrentUser() user: { id: string },
): Promise<Array<{ workflow: string; optedOut: boolean }>> {
const workflows = Array.from(OPTIONAL_WORKFLOWS);
const optedOutFlags = await Promise.all(
workflows.map((w) => this.preferences.isOptedOut(user.id, w)),
): Promise<Array<{ category: string; label: string; description: string; optedOut: boolean }>> {
return Promise.all(
NOTIFICATION_CATEGORIES.map(async (cat) => ({
category: cat.key,
label: cat.label,
description: cat.description,
optedOut: await this.preferences.getCategoryState(user.id, cat.key),
})),
);
return workflows.map((workflow, i) => ({ workflow, optedOut: optedOutFlags[i] }));
}
/** Toggle a single workflow's opt-out state from the settings UI. */
/** Toggle a whole category's opt-out state from the settings UI. */
@Post()
async update(
@CurrentUser() user: { id: string },
@Body() body: { workflow?: string; optedOut?: boolean },
): Promise<{ workflow: string; optedOut: boolean }> {
const { workflow, optedOut } = body;
if (!workflow || typeof workflow !== "string" || !OPTIONAL_WORKFLOWS.has(workflow)) {
throw new BadRequestException("invalid workflow");
@Body() body: { category?: string; optedOut?: boolean },
): Promise<{ category: string; optedOut: boolean }> {
const { category, optedOut } = body;
if (!isNotificationCategory(category)) {
throw new BadRequestException("invalid category");
}
if (typeof optedOut !== "boolean") {
throw new BadRequestException("optedOut must be boolean");
}
if (optedOut) {
await this.preferences.optOut(user.id, workflow, "settings_page");
} else {
await this.preferences.optIn(user.id, workflow);
}
await this.preferences.setCategoryState(user.id, category, optedOut, "settings_page");
this.logger.log(
`[email-prefs] user=${user.id} workflow=${workflow}${optedOut ? "opt-out" : "opt-in"}`,
`[email-prefs] user=${user.id} category=${category}${optedOut ? "opt-out" : "opt-in"}`,
);
return { workflow, optedOut };
return { category, optedOut };
}
}

View File

@@ -9,6 +9,10 @@ import * as schema from "../database/schema/core";
* NOT in this set — they're transactional and must reach the user (the
* compliance argument is the same as Stripe's "we still send receipts even
* if you unsubscribed from marketing").
*
* `mobile_push` is a pseudo-workflow — we don't ship push notifications yet
* but we accept the opt-out preference now so the toggle in the settings UI
* means something when mobile lands.
*/
export const OPTIONAL_WORKFLOWS = new Set<string>([
"welcome",
@@ -17,8 +21,54 @@ export const OPTIONAL_WORKFLOWS = new Set<string>([
"referral",
"referral-qualified",
"referral-reward",
"mobile_push",
]);
/**
* User-facing notification categories. Each category bundles one or more
* underlying workflow names — the settings UI shows ONE toggle per category
* (not per workflow), because users care about "e-mail vs mobile" not "did
* the day-3 referral nudge fire". The category is opted-out when EVERY
* underlying workflow is opted-out; toggling it OFF inserts an opt-out row
* for each workflow, toggling ON deletes them all. mailAudit.md §9.3 #14
* follow-on.
*
* Order matters — that's the order the settings UI renders. Keep
* `email_marketing` first since it's the bigger lever today.
*/
export const NOTIFICATION_CATEGORIES = [
{
key: "email_marketing" as const,
label: "E-posta bildirimleri",
description:
"Hoş geldin, deneme bitişi, davet hatırlatması, ödül ve geri kazanma mailleri. Hesap güvenliği ve ödeme bildirimleri her zaman gelir.",
workflows: [
"welcome",
"trial-ending",
"win-back",
"referral",
"referral-qualified",
"referral-reward",
] as const,
},
{
key: "mobile_push" as const,
label: "Mobil bildirim",
description:
"Mobil uygulama push bildirimleri. Mobil uygulama yayınlandığında bu tercih kullanılır.",
workflows: ["mobile_push"] as const,
},
] as const;
export type NotificationCategoryKey = (typeof NOTIFICATION_CATEGORIES)[number]["key"];
export function isNotificationCategory(value: unknown): value is NotificationCategoryKey {
return (
typeof value === "string" &&
NOTIFICATION_CATEGORIES.some((c) => c.key === (value as NotificationCategoryKey))
);
}
/**
* Stateless HMAC token in the List-Unsubscribe URL — no DB lookup needed to
* validate. Anyone holding the token can opt out, but only the server can
@@ -100,4 +150,42 @@ export class EmailPreferencesService {
),
);
}
/**
* Compute the opt-out state for a UI-facing category. A category is treated
* as "off" only when EVERY underlying workflow has been opted out — that
* way a stale per-workflow row from an earlier UI version doesn't make the
* category look off when it isn't.
*/
async getCategoryState(userId: string, key: NotificationCategoryKey): Promise<boolean> {
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
if (!cat) return false;
const states = await Promise.all(
cat.workflows.map((w) => this.isOptedOut(userId, w)),
);
return states.length > 0 && states.every(Boolean);
}
/**
* Apply the user's category toggle to every underlying workflow. We don't
* try to be cute about "the user only toggled the category once, only some
* workflows are opted out" edge cases — toggling off means off for all,
* toggling on means on for all.
*/
async setCategoryState(
userId: string,
key: NotificationCategoryKey,
optedOut: boolean,
source: string,
): Promise<void> {
const cat = NOTIFICATION_CATEGORIES.find((c) => c.key === key);
if (!cat) {
this.logger.warn(`refusing setCategoryState on unknown category ${key}`);
return;
}
for (const w of cat.workflows) {
if (optedOut) await this.optOut(userId, w, source);
else await this.optIn(userId, w);
}
}
}