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
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:
67
apps/api/src/notifications/email-preferences.controller.ts
Normal file
67
apps/api/src/notifications/email-preferences.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const SettingsContent = lazy(() =>
|
||||
export const SETTINGS_TABS = [
|
||||
"profile",
|
||||
"preferences",
|
||||
"notifications",
|
||||
"security",
|
||||
"connections",
|
||||
"referral",
|
||||
|
||||
Reference in New Issue
Block a user