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

View File

@@ -26,6 +26,7 @@ import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
Copy,
Eye,
EyeOff,
@@ -45,12 +46,61 @@ import { useEffect, useState } from "react";
const TAB_ITEMS = [
{ value: "profile", icon: User, labelKey: "settings.tabs.profile" },
{ value: "preferences", icon: SlidersHorizontal, labelKey: "settings.tabs.preferences" },
{ value: "notifications", icon: Bell, labelKey: "settings.tabs.notifications" },
{ value: "security", icon: Shield, labelKey: "settings.tabs.security" },
{ value: "connections", icon: Link2, labelKey: "settings.tabs.connections" },
{ value: "referral", icon: Gift, labelKey: "settings.tabs.referral" },
{ value: "account", icon: Trash2, labelKey: "settings.tabs.account" },
] as const;
/**
* Per-workflow opt-out labels for the Notifications tab. Order matters —
* it's the order the user sees. Auth + payment workflows are deliberately
* NOT here (transactional → must always reach the user). Kept in sync with
* apps/api/src/notifications/email-preferences.service.ts OPTIONAL_WORKFLOWS.
*/
const NOTIFICATION_WORKFLOWS: ReadonlyArray<{
workflow: string;
title: string;
description: string;
}> = [
{
workflow: "welcome",
title: "Hoş geldin maili",
description: "Kayıt olduktan hemen sonra gelen kısa karşılama.",
},
{
workflow: "trial-ending",
title: "Deneme bitiş hatırlatması",
description: "Deneme süresinin son birkaç gününde gönderilen yükseltme önerisi.",
},
{
workflow: "referral",
title: "Davet hatırlatması",
description: "Kayıt olduktan 3 gün sonra arkadaşını davet etme hatırlatması.",
},
{
workflow: "referral-qualified",
title: "Davet niteliği bildirimi",
description: "Davet ettiğin biri e-postasını doğrulayıp niteliklendiğinde haber alıyorsun.",
},
{
workflow: "referral-reward",
title: "Ödül bildirimi",
description: "Davet ödülü kazandığında (7 / 14 gün) bilgilendirme.",
},
{
workflow: "win-back",
title: "Geri kazanma maili",
description: "Uzun süre pasif kaldığında tek seferlik dönüş daveti.",
},
];
interface NotificationPref {
workflow: string;
optedOut: boolean;
}
type ThemePref = "light" | "dark" | "system";
const THEME_OPTIONS: { value: ThemePref; icon: typeof Sun; labelKey: string }[] = [
@@ -326,6 +376,11 @@ export function SettingsContent({
</TabsContent>
{/* Preferences Tab */}
{/* Notifications Tab (audit §9.3 #14) */}
<TabsContent value="notifications">
<NotificationsCard />
</TabsContent>
<TabsContent value="preferences">
<Card>
<CardHeader>
@@ -620,3 +675,114 @@ export function SettingsContent({
</div>
);
}
/**
* Per-workflow opt-out toggles for the lifecycle / engagement e-mails.
* Pure presentation — heavy lifting (HMAC token, audit row) is on the API
* side. Auth + payment mail is unaffected (`OPTIONAL_WORKFLOWS` on the
* server is the canonical list).
*/
function NotificationsCard() {
const { t } = useTranslation();
const [prefs, setPrefs] = useState<NotificationPref[] | null>(null);
const [pending, setPending] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.get<NotificationPref[]>("/email/preferences")
.then((data) => {
if (!cancelled) setPrefs(data);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message || "Hata");
});
return () => {
cancelled = true;
};
}, []);
async function toggle(workflow: string, currentlyOptedOut: boolean) {
const next = !currentlyOptedOut;
// Optimistic update — flip the local row immediately so the switch feels
// instant; revert on failure.
setPrefs((cur) =>
cur ? cur.map((p) => (p.workflow === workflow ? { ...p, optedOut: next } : p)) : cur,
);
setPending((s) => new Set(s).add(workflow));
try {
await api.post("/email/preferences", { workflow, optedOut: next });
capture(next ? "email_workflow_opted_out" : "email_workflow_opted_in", { workflow });
toast.success(next ? "Bildirim kapatıldı." : "Bildirim açıldı.");
} catch (err) {
// Revert + surface the error.
setPrefs((cur) =>
cur
? cur.map((p) =>
p.workflow === workflow ? { ...p, optedOut: currentlyOptedOut } : p,
)
: cur,
);
toast.error((err as Error).message || "Güncellenemedi");
} finally {
setPending((s) => {
const next = new Set(s);
next.delete(workflow);
return next;
});
}
}
return (
<Card>
<CardHeader>
<CardTitle>{t("settings.notifications.title")}</CardTitle>
<CardDescription>{t("settings.notifications.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : prefs === null ? (
<div className="space-y-3">
{NOTIFICATION_WORKFLOWS.map((w) => (
<Skeleton key={w.workflow} className="h-16 w-full" />
))}
</div>
) : (
NOTIFICATION_WORKFLOWS.map((w) => {
const row = prefs.find((p) => p.workflow === w.workflow);
const optedOut = row?.optedOut ?? false;
const isPending = pending.has(w.workflow);
return (
<div
key={w.workflow}
className="flex items-start justify-between gap-4 rounded-lg border p-4"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{w.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{w.description}</p>
</div>
<Button
type="button"
variant={optedOut ? "outline" : "default"}
size="sm"
disabled={isPending}
onClick={() => toggle(w.workflow, optedOut)}
aria-pressed={!optedOut}
aria-label={`${w.title}: ${optedOut ? "kapalı" : "açık"}`}
>
{isPending ? "…" : optedOut ? "Kapalı" : "Açık"}
</Button>
</div>
);
})
)}
<p className="text-xs text-muted-foreground">
Doğrulama, şifre sıfırlama ve ödeme bildirimleri buradan kapatılamaz hesabını
yönetebilmen için gerekli.
</p>
</CardContent>
</Card>
);
}

View File

@@ -497,7 +497,8 @@
"connections": "Connections",
"referral": "Referral",
"account": "Account",
"changelog": "Changelog"
"changelog": "Changelog",
"notifications": "Notifications"
},
"preferences": {
"title": "Preferences",
@@ -577,6 +578,10 @@
"feature": "New Feature",
"improvement": "Improvement"
}
},
"notifications": {
"title": "Notification preferences",
"description": "Choose which lifecycle e-mails you want to receive. Account security and payment notifications keep coming."
}
},
"errors": {
@@ -612,10 +617,22 @@
},
"yearlyBadge": "2 months free",
"stats": {
"brands": { "value": "27", "label": "brand catalogs" },
"parts": { "value": "1M+", "label": "OEM & alternative parts" },
"trial": { "value": "30 days", "label": "full access, free" },
"refund": { "value": "7 days", "label": "no-questions refund" }
"brands": {
"value": "27",
"label": "brand catalogs"
},
"parts": {
"value": "1M+",
"label": "OEM & alternative parts"
},
"trial": {
"value": "30 days",
"label": "full access, free"
},
"refund": {
"value": "7 days",
"label": "no-questions refund"
}
},
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans",
"how": {
@@ -926,10 +943,22 @@
"titleLine1": "Sase.tr",
"titleLine2": "in Numbers",
"items": {
"0": { "value": "1.2sn", "label": "Average lookup time" },
"1": { "value": "27", "label": "Supported brands" },
"2": { "value": "1M+", "label": "OEM part numbers" },
"3": { "value": "%99.9", "label": "Platform uptime" }
"0": {
"value": "1.2sn",
"label": "Average lookup time"
},
"1": {
"value": "27",
"label": "Supported brands"
},
"2": {
"value": "1M+",
"label": "OEM part numbers"
},
"3": {
"value": "%99.9",
"label": "Platform uptime"
}
}
},
"dashboard": {
@@ -999,9 +1028,18 @@
"bullet4": "White-label — the widget matches your site's design",
"cta": "Get in Touch",
"stats": {
"0": { "value": "%42", "label": "Fewer Returns" },
"1": { "value": "%35", "label": "Higher Conversion" },
"2": { "value": "<30dk", "label": "Integration Time" }
"0": {
"value": "%42",
"label": "Fewer Returns"
},
"1": {
"value": "%35",
"label": "Higher Conversion"
},
"2": {
"value": "<30dk",
"label": "Integration Time"
}
}
},
"testimonials": {
@@ -1130,4 +1168,4 @@
"decodeGeneric": "Something went wrong. Please try again."
}
}
}
}

View File

@@ -497,7 +497,8 @@
"connections": "Bağlantılar",
"referral": "Referans",
"account": "Hesap",
"changelog": "Değişiklik Günlüğü"
"changelog": "Değişiklik Günlüğü",
"notifications": "Bildirimler"
},
"preferences": {
"title": "Tercihler",
@@ -577,6 +578,10 @@
"feature": "Yeni Özellik",
"improvement": "Geliştirme"
}
},
"notifications": {
"title": "Bildirim tercihleri",
"description": "Hangi lifecycle maillerini almak istediğini seç. Hesap güvenliği ve ödeme bildirimleri her zaman gelmeye devam eder."
}
},
"errors": {
@@ -612,10 +617,22 @@
},
"yearlyBadge": "2 ay bedava",
"stats": {
"brands": { "value": "27", "label": "marka kataloğu" },
"parts": { "value": "1M+", "label": "OEM ve alternatif parça" },
"trial": { "value": "30 gün", "label": "tüm özellikler ücretsiz" },
"refund": { "value": "7 gün", "label": "koşulsuz iade" }
"brands": {
"value": "27",
"label": "marka kataloğu"
},
"parts": {
"value": "1M+",
"label": "OEM ve alternatif parça"
},
"trial": {
"value": "30 gün",
"label": "tüm özellikler ücretsiz"
},
"refund": {
"value": "7 gün",
"label": "koşulsuz iade"
}
},
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
"how": {
@@ -926,10 +943,22 @@
"titleLine1": "Rakamlarla",
"titleLine2": "Sase.tr",
"items": {
"0": { "value": "1.2sn", "label": "Ortalama sorgu süresi" },
"1": { "value": "27", "label": "Desteklenen marka" },
"2": { "value": "1M+", "label": "OEM parça numarası" },
"3": { "value": "%99.9", "label": "Platform erişilebilirlik" }
"0": {
"value": "1.2sn",
"label": "Ortalama sorgu süresi"
},
"1": {
"value": "27",
"label": "Desteklenen marka"
},
"2": {
"value": "1M+",
"label": "OEM parça numarası"
},
"3": {
"value": "%99.9",
"label": "Platform erişilebilirlik"
}
}
},
"dashboard": {
@@ -999,9 +1028,18 @@
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
"cta": "İletişime Geçin",
"stats": {
"0": { "value": "%42", "label": "Daha Az İade" },
"1": { "value": "%35", "label": "Daha Yüksek Dönüşüm" },
"2": { "value": "<30dk", "label": "Entegrasyon Süresi" }
"0": {
"value": "%42",
"label": "Daha Az İade"
},
"1": {
"value": "%35",
"label": "Daha Yüksek Dönüşüm"
},
"2": {
"value": "<30dk",
"label": "Entegrasyon Süresi"
}
}
},
"testimonials": {
@@ -1130,4 +1168,4 @@
"decodeGeneric": "Bir hata oluştu. Lütfen tekrar deneyin."
}
}
}
}

View File

@@ -11,6 +11,7 @@ const SettingsContent = lazy(() =>
export const SETTINGS_TABS = [
"profile",
"preferences",
"notifications",
"security",
"connections",
"referral",