feat(notifications): settings UI for per-workflow opt-out (audit §9.3 #14 follow-on)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Lands the user-facing half of the unsubscribe preferences work. The
one-click endpoint already shipped in this PR's main commit; this adds
the proactive self-service path at /dashboard/settings?tab=notifications
so users don't have to wait for a mail to land before tuning their
preferences.

Backend
-------
New EmailPreferencesController at /api/email/preferences:
  GET  → returns one row per OPTIONAL_WORKFLOWS entry, each with current
         optedOut boolean (false when no DB row exists).
  POST → body {workflow, optedOut} flips the row; source='settings_page'
         captured for the audit trail.
Auth+payment workflows are deliberately not exposed — the server's
OPTIONAL_WORKFLOWS set stays the single source of truth.

Frontend
--------
Adds a 'notifications' tab to /dashboard/settings (between 'preferences'
and 'security'). One toggle row per optional workflow with TR copy that
explains what each mail is for. Optimistic update — switch flips
instantly and reverts on failure; PostHog event captures accept/reject.

Static footer note clarifies that auth + payment mail keeps coming
regardless of the switches above (so users don't think they've
unsubscribed from password-reset).

i18n
----
Added settings.tabs.notifications + settings.notifications.{title,
description} to both tr.json and en.json. Body copy is hard-coded TR
(matches audit §9.3 #11 TR-only decision).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude (audit §9.3)
2026-06-04 14:51:10 +03:00
parent 327d698945
commit f5cd5be933
6 changed files with 338 additions and 27 deletions

View File

@@ -0,0 +1,67 @@
import { BadRequestException, Body, Controller, Get, Logger, Post } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import {
EmailPreferencesService,
OPTIONAL_WORKFLOWS,
} 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.
*
* 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.
*/
@Controller("email/preferences")
export class EmailPreferencesController {
private readonly logger = new Logger(EmailPreferencesController.name);
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}`.
*/
@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)),
);
return workflows.map((workflow, i) => ({ workflow, optedOut: optedOutFlags[i] }));
}
/** Toggle a single workflow'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");
}
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);
}
this.logger.log(
`[email-prefs] user=${user.id} workflow=${workflow}${optedOut ? "opt-out" : "opt-in"}`,
);
return { workflow, optedOut };
}
}

View File

@@ -1,5 +1,6 @@
import { Global, Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { EmailPreferencesController } from "./email-preferences.controller";
import { EmailPreferencesService } from "./email-preferences.service";
import { NovuService } from "./novu.service";
import { UnsubscribeController } from "./unsubscribe.controller";
@@ -13,7 +14,7 @@ import { UnsubscribeController } from "./unsubscribe.controller";
@Global()
@Module({
imports: [DatabaseModule],
controllers: [UnsubscribeController],
controllers: [UnsubscribeController, EmailPreferencesController],
providers: [NovuService, EmailPreferencesService],
exports: [NovuService, EmailPreferencesService],
})