Merge pull request 'dev' (#105) from dev into main

Reviewed-on: #105
This commit was merged in pull request #105.
This commit is contained in:
2026-06-04 15:22:09 +00:00
27 changed files with 1514 additions and 72 deletions

View File

@@ -0,0 +1,12 @@
CREATE TABLE "email_preferences" (
"user_id" uuid NOT NULL,
"workflow" varchar(64) NOT NULL,
"opted_out" boolean DEFAULT true NOT NULL,
"source" varchar(32) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "email_preferences" ADD CONSTRAINT "email_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "email_preferences_pk" ON "email_preferences" USING btree ("user_id","workflow");--> statement-breakpoint
CREATE INDEX "email_preferences_workflow_idx" ON "email_preferences" USING btree ("workflow");

View File

@@ -0,0 +1,8 @@
CREATE TABLE "lifecycle_email_sent" (
"user_id" uuid NOT NULL,
"workflow" varchar(64) NOT NULL,
"sent_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "lifecycle_email_sent" ADD CONSTRAINT "lifecycle_email_sent_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "lifecycle_email_sent_pk" ON "lifecycle_email_sent" USING btree ("user_id","workflow");

View File

@@ -78,6 +78,20 @@
"when": 1780281600000,
"tag": "0010_dedupe_parts",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1780572179333,
"tag": "0011_email_preferences",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780581755559,
"tag": "0012_lifecycle_email_sent",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { generateReferralCode } from "@sase/shared";
import { generateReferralCode, normalizeName } from "@sase/shared";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { captcha } from "better-auth/plugins";
@@ -139,9 +139,17 @@ export function createAuth(
user: {
create: {
before: async (userData) => {
// Postal logs show signup names arrive in every casing (`mehmet`,
// `MEHMET`, `İLKER`, `OTO`) and we render them straight into mail
// subjects — `Sase.tr'ye hoş geldin, mehmet` looks unprofessional.
// Canonicalise here so every downstream consumer (Novu subscriber,
// Stripe customer, dashboard greeting) sees one consistent form.
// Turkish-locale-aware (İ/ı handled).
const cleanedName = normalizeName(userData.name);
return {
data: {
...userData,
...(cleanedName ? { name: cleanedName } : {}),
referralCode: await generateUniqueReferralCode(db),
},
};

View File

@@ -53,7 +53,10 @@ export default () => ({
email: {
postalApiUrl: process.env.POSTAL_API_URL,
postalApiKey: process.env.POSTAL_API_KEY,
fromAddress: process.env.POSTAL_FROM_ADDRESS || "noreply@sase.tr",
// Default to `destek@sase.tr` so replies are inboxed; `noreply@` had no
// inbound route. Override with POSTAL_FROM_ADDRESS env if a workflow
// genuinely shouldn't accept replies. mailAudit.md §9.4 #16.
fromAddress: process.env.POSTAL_FROM_ADDRESS || "destek@sase.tr",
fromName: process.env.POSTAL_FROM_NAME || "Sase.tr",
},
novu: {

View File

@@ -539,6 +539,67 @@ export const blogPosts = pgTable(
],
);
// ─── Email Preferences (per-workflow unsubscribe state) ────────────────
//
// One row per (user, workflow) the user has explicitly opted out of. Absent
// rows mean "still subscribed" — we don't pre-seed because the default is
// always opt-in (with a List-Unsubscribe header in every mail) and creating
// per-user rows at signup would 10x the table size for no behaviour change.
//
// The `workflow` column maps to Novu trigger names (`welcome`, `win-back`,
// `referral`, `trial-ending`, `referral-qualified`, `referral-reward`).
// Auth flows (`email-verification`, `password-reset`, `payment-success`,
// `payment-failed`) are explicitly NOT respectful of this table — they're
// transactional and must reach the user.
export const emailPreferences = pgTable(
"email_preferences",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
workflow: varchar("workflow", { length: 64 }).notNull(),
// Always `true` while a row exists — column kept for future tri-state
// (subscribed / unsubscribed / digest-only). Row presence is the
// canonical signal today.
optedOut: boolean("opted_out").default(true).notNull(),
// Audit trail: which surface flipped the flag (one_click email,
// settings_page, admin_panel, …). Helps with abuse / wrong-user
// unsub investigations.
source: varchar("source", { length: 32 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("email_preferences_pk").on(table.userId, table.workflow),
index("email_preferences_workflow_idx").on(table.workflow),
],
);
// ─── Lifecycle Email Sent (idempotency guard) ─────────────────────────
//
// The daily cron used to rely on a 1-day endDate window for at-most-once
// semantics (`endDate ∈ [now+3d, now+4d)` for trial-ending; same shape for
// win-back). That worked until we missed a day (deploy outage, ramp pause):
// once the window slid past, the affected cohort silently got no mail.
//
// One row per (user, workflow) the cron has actually fired; queried via
// LEFT JOIN ... IS NULL so the cron only picks users it hasn't already
// sent to. If a day is skipped, the next run still catches yesterday's
// cohort because they still don't have a sent-row. mailAudit.md §9.4 #20.
export const lifecycleEmailSent = pgTable(
"lifecycle_email_sent",
{
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Trigger name, matches Novu workflow id (`trial-ending`, `win-back`).
workflow: varchar("workflow", { length: 64 }).notNull(),
// First time the cron actually triggered this mail; never overwritten.
sentAt: timestamp("sent_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("lifecycle_email_sent_pk").on(table.userId, table.workflow)],
);
// ─── EMEX Category Translations ─────────────────────
export const emexCategoryTranslations = pgTable(
"emex_category_translations",

View File

@@ -26,7 +26,11 @@ export class EmailService {
constructor(private configService: ConfigService) {
this.postalApiUrl = configService.get<string>("email.postalApiUrl");
this.postalApiKey = configService.get<string>("email.postalApiKey");
this.fromAddress = configService.get<string>("email.fromAddress") || "noreply@sase.tr";
// `destek@sase.tr` is monitored — replies go to the SnappyMail destek
// inbox so users who hit "reply" actually reach someone. Older default
// (`noreply@sase.tr`) had no inbound route and dropped replies.
// mailAudit.md §9.4 #16.
this.fromAddress = configService.get<string>("email.fromAddress") || "destek@sase.tr";
this.fromName = configService.get<string>("email.fromName") || "Sase.tr";
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { PL24Service } from "./pl24.service";
// configService.get(key, default) → default; everything else unused by parseVehicleResponse.
const configStub = { get: (_k: string, d?: unknown) => d } as never;
const svc = new PL24Service(
{} as never, // authService
{} as never, // fordLegacyService
{} as never, // psaService
{} as never, // volvoService
{} as never, // fordService
{} as never, // opelService
{} as never, // hyundaiKiaService
configStub, // configService
{} as never, // redis
{} as never, // storage
);
const p = svc as unknown as {
parseVehicleResponse(
vin: string,
data: Record<string, unknown>,
serviceName: string,
): {
brand: string;
model: string;
year: number;
productionDate: string | null;
catalogInfo?: { mainGroupsPath?: string };
};
};
describe("PL24Service.parseVehicleResponse — p5fiat shape", () => {
// Real /p5fiat directAccess shape (captured live 2026-06-04, de-708171):
// vinfoBasic records are {key,description,code?}, NOT {values:{description,value}}.
const fiatData = {
resultStatus: "VEHICLE_IDENTIFIED",
description: "319",
link: {
wid: "mainGroupsTable",
path: "/p5fiat/extern/groups/vin/maingroups?allMarkets=true&lang=tr&modelCode=33&serviceName=fiatp_parts&vin=ZFA31200003376360",
},
segments: {
vinfoBasic: {
records: [
{
description: "ZFA31200003376360",
values: { key: "Sasi numarasi", description: "ZFA31200003376360" },
},
{
description: "319118001000 (33)",
values: { key: "Model", description: "319118001000 (33)" },
},
{
description: "Panda POP 1.2 8V 69CV 5M E6",
values: { key: "Model bilgisi", description: "Panda POP 1.2 8V 69CV 5M E6" },
},
{
description: "05/09/2014",
values: { key: "Üretim tarihi", description: "05/09/2014" },
},
{
description: "MODEL YILI = 2014 YILI",
values: { key: "MY", code: "NO", description: "MODEL YILI = 2014 YILI" },
},
{
description: "GEARBOX DESIGN = MECHANIC TRANSMISSION",
values: {
key: "PC",
code: "C514",
description: "GEARBOX DESIGN = MECHANIC TRANSMISSION",
},
},
],
},
},
};
it("reads the friendly model from 'Model bilgisi' (not the numeric 'Model' code)", () => {
const r = p.parseVehicleResponse("ZFA31200003376360", fiatData, "fiatp_parts");
expect(r.brand).toBe("Fiat");
expect(r.model).toBe("Panda POP 1.2 8V 69CV 5M E6");
});
it("derives year + production date when there is no plain model_yili field", () => {
const r = p.parseVehicleResponse("ZFA31200003376360", fiatData, "fiatp_parts");
expect(r.year).toBe(2014);
expect(r.productionDate).toBe("05/09/2014");
});
it("uses link.path as the main-groups path", () => {
const r = p.parseVehicleResponse("ZFA31200003376360", fiatData, "fiatp_parts");
expect(r.catalogInfo?.mainGroupsPath).toContain("/p5fiat/extern/groups/vin/maingroups");
});
});
describe("PL24Service.parseVehicleResponse — p5vwag shape still works (regression)", () => {
const vwagData = {
resultStatus: "VEHICLE_IDENTIFIED",
description: "Golf - 1.6 TDI",
link: { path: "/p5vwag/extern/groups/vin/maingroups?vin=WVWZZZ" },
segments: {
vinfoBasic: {
records: [
{ values: { description: "Model", value: "Golf" } },
{ values: { description: "Model yili", value: "2018" } },
],
},
},
};
it("reads model/year from the {values:{description,value}} shape", () => {
const r = p.parseVehicleResponse("WVWZZZ1KZAW000000", vwagData as never, "vw_parts");
expect(r.model).toBe("Golf");
expect(r.year).toBe(2018);
expect(r.catalogInfo?.mainGroupsPath).toContain("/p5vwag/");
});
});

View File

@@ -997,16 +997,24 @@ export class PL24Service {
serviceName: string,
): Omit<PL24DecodedVehicle, "categories"> {
const segments =
(data.segments as Record<string, { records?: Array<{ values: Record<string, string> }> }>) ||
{};
(data.segments as Record<
string,
{ records?: Array<{ values?: Record<string, string | undefined> }> }
>) || {};
const vinfoRecords = segments.vinfoBasic?.records || [];
const vehicleData: Record<string, string> = {};
for (const record of vinfoRecords) {
if (record.values) {
const key = record.values.description?.toLowerCase().replace(/[\s\/]+/g, "_") || "";
vehicleData[key] = record.values.value?.replace(/\r?\n/g, " ").trim() || "";
}
const v = record.values;
if (!v) continue;
// Both shapes carry the row under `values`, but the inner field names differ:
// p5vwag etc.: { description: <label>, value: <value> }
// p5fiat: { key: <label>, description: <value>, code?: <code> }
const label = (v.key !== undefined ? v.key : v.description) || "";
const value = (v.key !== undefined ? v.description : v.value) || "";
if (!label) continue;
const key = label.toLowerCase().replace(/[\s\/]+/g, "_");
if (!(key in vehicleData)) vehicleData[key] = value.replace(/\r?\n/g, " ").trim();
}
// Helper: look up by multiple possible keys (EN + TR)
@@ -1063,8 +1071,22 @@ export class PL24Service {
return {
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
model: lookup("model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || "",
year: Number.parseInt(lookup("model_yili", "year") || "", 10) || extractModelYear(vin) || 0,
// p5fiat carries the friendly model in "Model bilgisi" ("Panda POP 1.2 8V 69CV 5M E6");
// its plain "Model" is a numeric code. Other P5 backends only have "model".
model:
lookup("model_bilgisi", "model")?.trim() ||
(data.description as string)?.split(" - ")[0]?.trim() ||
"",
// p5fiat has no plain model-year field; derive it from "MY" ("… = 2014 YILI") or the
// production date ("05/09/2014"). Other P5 backends use a plain model_yili/year number.
year:
Number.parseInt(lookup("model_yili", "year") || "", 10) ||
Number.parseInt(
(lookup("my", "üretim_tarihi", "uretim_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "",
10,
) ||
extractModelYear(vin) ||
0,
series: lookup("satis_tipi", "sales_type"),
bodyType,
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),

View File

@@ -362,17 +362,20 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
architecture: "LEGACY_VOLVO",
},
// Fiat Group (FCA/Stellantis) — P4 Legacy, requires de-708171 account
// NOTE: basePath/apiPath require Playwright verification with de account
// Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account.
// Verified live 2026-06-04: launchCatalog.do → /pl24-app (P5 SPA), backend /p5fiat;
// directAccess + maingroups/subgroups/parts/images are standard P5. Covers European
// (ZFA) Fiats + some commercial Tofaş (fiatt); Turkish Tofaş passenger (NM4, incl. Egea)
// is NOT in this catalog. de auth handshake is proxied; catalog data needs no proxy.
fiatp_parts: {
basePath: "/fca",
apiPath: "/fca",
architecture: "LEGACY_FIAT",
basePath: "/p5fiat",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
fiatt_parts: {
basePath: "/fca",
apiPath: "/fca",
architecture: "LEGACY_FIAT",
basePath: "/p5fiat",
apiPath: "/p5fiat",
architecture: "P5_MODERN",
},
};

View File

@@ -1,22 +1,37 @@
import { Job } from "bullmq";
import { and, eq, gt, gte, inArray, lt } from "drizzle-orm";
import { and, eq, gt, gte, inArray, isNull, lt } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { userSubscriptions, users } from "../../database/schema/core";
import { buildTrackedUrl, firstNameOf, triggerNovu, webUrl } from "../../notifications/novu";
import {
emailPreferences,
lifecycleEmailSent,
userSubscriptions,
users,
} from "../../database/schema/core";
import {
buildTrackPixelUrl,
buildTrackedUrl,
firstNameOf,
triggerNovu,
webUrl,
} from "../../notifications/novu";
type Database = PostgresJsDatabase<Record<string, unknown>>;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Daily lifecycle e-mail cron. Two cohorts, each defined by a 1-day endDate
* window so a daily run sends to each user exactly once without needing a
* "sent" flag column:
* Daily lifecycle e-mail cron. Two cohorts:
*
* • trial-ending — trials whose endDate is 34 days out (→ "3 days left").
* • win-back — users whose access ended 78 days ago (expired / lapsed
* trial / cancelled) and who have no live subscription now.
*
* At-most-once is enforced by an explicit `lifecycle_email_sent` row per
* (user, workflow) — written immediately after each successful trigger and
* LEFT-JOINed away on the next run. This replaces the older "1-day window
* is the idempotency" trick, which lost a cohort whenever the cron skipped
* a day (deploy outage, ramp pause). mailAudit.md §9.4 #20.
*
* Triggers go straight through the framework-agnostic Novu client (this runs
* in the standalone BullMQ worker, which has no NestJS DI).
*/
@@ -39,6 +54,9 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
const windowStart = new Date(now.getTime() + 3 * DAY_MS);
const windowEnd = new Date(now.getTime() + 4 * DAY_MS);
// Two LEFT JOINs do the filtering in one round-trip:
// • email_preferences → IS NULL → user hasn't opted out
// • lifecycle_email_sent → IS NULL → we haven't already sent this mail
const rows = await db
.select({
userId: userSubscriptions.userId,
@@ -47,15 +65,32 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "trial-ending"),
),
)
.leftJoin(
lifecycleEmailSent,
and(
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "trial-ending"),
),
)
.where(
and(
eq(userSubscriptions.status, "trial"),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
isNull(emailPreferences.userId),
isNull(lifecycleEmailSent.userId),
),
);
for (const r of rows) {
const trackPixel = buildTrackPixelUrl("trial-ending", r.email) ?? undefined;
await triggerNovu(
"trial-ending",
{ subscriberId: r.userId, email: r.email, firstName: firstNameOf(r.name) },
@@ -66,8 +101,17 @@ async function sendTrialEnding(db: Database, now: Date): Promise<number> {
r.email,
webUrl("/dashboard/subscription"),
),
...(trackPixel ? { trackPixel } : {}),
},
);
// Record the send *after* triggerNovu so a Novu API hiccup doesn't burn
// the row. triggerNovu is fire-safe (never throws) so we can't observe
// its outcome here, but in practice a 5xx still writes the row — which
// is fine: the e-mail will eventually go via Novu's own retry queue.
await db
.insert(lifecycleEmailSent)
.values({ userId: r.userId, workflow: "trial-ending" })
.onConflictDoNothing();
}
return rows.length;
}
@@ -88,11 +132,27 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
})
.from(userSubscriptions)
.innerJoin(users, eq(userSubscriptions.userId, users.id))
.leftJoin(
emailPreferences,
and(
eq(emailPreferences.userId, users.id),
eq(emailPreferences.workflow, "win-back"),
),
)
.leftJoin(
lifecycleEmailSent,
and(
eq(lifecycleEmailSent.userId, users.id),
eq(lifecycleEmailSent.workflow, "win-back"),
),
)
.where(
and(
inArray(userSubscriptions.status, ["expired", "trial", "cancelled"]),
gte(userSubscriptions.endDate, windowStart),
lt(userSubscriptions.endDate, windowEnd),
isNull(emailPreferences.userId),
isNull(lifecycleEmailSent.userId),
),
);
@@ -116,11 +176,19 @@ async function sendWinBack(db: Database, now: Date): Promise<number> {
.limit(1);
if (live) continue;
const trackPixel = buildTrackPixelUrl("win-back", c.email) ?? undefined;
await triggerNovu(
"win-back",
{ subscriberId: c.userId, email: c.email, firstName: firstNameOf(c.name) },
{ ctaUrl: buildTrackedUrl("win-back", c.email, webUrl("/dashboard")) },
{
ctaUrl: buildTrackedUrl("win-back", c.email, webUrl("/dashboard")),
...(trackPixel ? { trackPixel } : {}),
},
);
await db
.insert(lifecycleEmailSent)
.values({ userId: c.userId, workflow: "win-back" })
.onConflictDoNothing();
sent++;
}
return sent;

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

@@ -0,0 +1,103 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { Inject, Injectable, Logger } from "@nestjs/common";
import { and, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import * as schema from "../database/schema/core";
/**
* Workflows the user can opt out of. Auth + payment flows are deliberately
* 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").
*/
export const OPTIONAL_WORKFLOWS = new Set<string>([
"welcome",
"trial-ending",
"win-back",
"referral",
"referral-qualified",
"referral-reward",
]);
/**
* 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
* mint one (the secret never leaves the API). Rotating UNSUBSCRIBE_SECRET
* invalidates every outstanding token, which is a useful nuke-button if a
* mail leak ever surfaces.
*/
export function signUnsubscribeToken(secret: string, userId: string, workflow: string): string {
return createHmac("sha256", secret).update(`${userId}|${workflow}`).digest("hex");
}
export function verifyUnsubscribeToken(
secret: string,
userId: string,
workflow: string,
token: string,
): boolean {
if (!secret) return false;
if (!/^[0-9a-f]+$/i.test(token) || token.length % 2 !== 0) return false;
const expected = signUnsubscribeToken(secret, userId, workflow);
if (expected.length !== token.length) return false;
try {
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(token, "hex"));
} catch {
return false;
}
}
@Injectable()
export class EmailPreferencesService {
private readonly logger = new Logger(EmailPreferencesService.name);
constructor(@Inject(DATABASE) private readonly db: Database) {}
/** True if the user has explicitly opted out of `workflow`. */
async isOptedOut(userId: string, workflow: string): Promise<boolean> {
if (!OPTIONAL_WORKFLOWS.has(workflow)) return false;
const [row] = await this.db
.select({ optedOut: schema.emailPreferences.optedOut })
.from(schema.emailPreferences)
.where(
and(
eq(schema.emailPreferences.userId, userId),
eq(schema.emailPreferences.workflow, workflow),
),
)
.limit(1);
return row?.optedOut === true;
}
/**
* Mark a (user, workflow) pair as opted-out. Idempotent — re-clicking the
* unsubscribe link doesn't error, just no-ops the row's updated_at.
* `source` is captured for audit (`one_click`, `settings_page`,
* `admin_panel`, …).
*/
async optOut(userId: string, workflow: string, source: string): Promise<void> {
if (!OPTIONAL_WORKFLOWS.has(workflow)) {
this.logger.warn(`refusing optOut on non-optional workflow ${workflow}`);
return;
}
await this.db
.insert(schema.emailPreferences)
.values({ userId, workflow, optedOut: true, source })
.onConflictDoUpdate({
target: [schema.emailPreferences.userId, schema.emailPreferences.workflow],
set: { optedOut: true, source, updatedAt: new Date() },
});
}
/** Re-subscribe — used by the dashboard settings UI when a user toggles back on. */
async optIn(userId: string, workflow: string): Promise<void> {
await this.db
.delete(schema.emailPreferences)
.where(
and(
eq(schema.emailPreferences.userId, userId),
eq(schema.emailPreferences.workflow, workflow),
),
);
}
}

View File

@@ -1,14 +1,21 @@
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";
/**
* Global so any module can inject NovuService without re-importing — mirrors
* EmailModule. The standalone BullMQ worker does not use this module; it calls
* the framework-agnostic helpers in ./novu directly.
* Global so any module can inject NovuService / EmailPreferencesService
* without re-importing — mirrors EmailModule. The standalone BullMQ worker
* does not use this module; it calls the framework-agnostic helpers in
* ./novu directly.
*/
@Global()
@Module({
providers: [NovuService],
exports: [NovuService],
imports: [DatabaseModule],
controllers: [UnsubscribeController, EmailPreferencesController],
providers: [NovuService, EmailPreferencesService],
exports: [NovuService, EmailPreferencesService],
})
export class NotificationsModule {}

View File

@@ -1,6 +1,8 @@
import { Injectable, Logger } from "@nestjs/common";
import { EmailPreferencesService } from "./email-preferences.service";
import {
type NovuRecipient,
buildTrackPixelUrl,
buildTrackedUrl,
firstNameOf,
formatTrDate,
@@ -9,13 +11,21 @@ import {
webUrl,
} from "./novu";
/**
* Best-effort open-pixel URL for marketing / lifecycle workflows. Auth +
* payment workflows deliberately skip the pixel — they don't render it and
* shipping a tracking URL on a transactional mail is a (small but real)
* privacy nudge we don't need. mailAudit.md §9.4 #17.
*/
function trackPixelFor(workflow: string, email: string): string | undefined {
return buildTrackPixelUrl(workflow, email) ?? undefined;
}
/** Minimal user shape needed to address a Novu trigger. */
export interface NovuUser {
id: string;
email: string;
name?: string | null;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string | null;
}
/**
@@ -30,17 +40,38 @@ export interface NovuUser {
export class NovuService {
private readonly logger = new Logger(NovuService.name);
constructor(private readonly preferences: EmailPreferencesService) {}
private to(user: NovuUser): NovuRecipient {
return {
subscriberId: user.id,
email: user.email,
firstName: firstNameOf(user.name),
// No locale column yet → Turkish default. Set "en" here once stored.
...(user.locale === "en" ? { locale: "en" } : {}),
};
}
private trigger(name: string, user: NovuUser, payload: Record<string, unknown> = {}) {
/**
* Skip the trigger if the user has opted out of this workflow.
* Pre-flight check is best-effort — a DB hiccup must not block the trigger
* (auth+payment workflows must still fire), so on lookup failure we log
* and send anyway.
*/
private async shouldSend(userId: string, name: string): Promise<boolean> {
try {
const optedOut = await this.preferences.isOptedOut(userId, name);
if (optedOut) {
this.logger.log(`[novu] skipped "${name}" → user=${userId} (opted out)`);
return false;
}
return true;
} catch (err) {
this.logger.warn(`[novu] preference check failed for "${name}": ${String(err)}`);
return true; // fail-open so a DB blip doesn't silently swallow mail
}
}
private async trigger(name: string, user: NovuUser, payload: Record<string, unknown> = {}) {
if (!(await this.shouldSend(user.id, name))) return;
return triggerNovu(name, this.to(user), payload, this.logger);
}
@@ -48,6 +79,7 @@ export class NovuService {
async welcome(user: NovuUser): Promise<void> {
await this.trigger("welcome", user, {
ctaUrl: buildTrackedUrl("welcome", user.email, webUrl("/dashboard")),
trackPixel: trackPixelFor("welcome", user.email),
});
}
@@ -59,6 +91,7 @@ export class NovuService {
async referralInvite(user: NovuUser, referralCode?: string | null): Promise<void> {
const payload: Record<string, unknown> = {
ctaUrl: buildTrackedUrl("referral", user.email, webUrl("/dashboard/settings?tab=referral")),
trackPixel: trackPixelFor("referral", user.email),
};
if (referralCode) {
payload.referralUrl = webUrl(`/register?ref=${encodeURIComponent(referralCode)}`);
@@ -92,6 +125,7 @@ export class NovuService {
referrer.email,
webUrl("/dashboard/settings?tab=referral"),
),
trackPixel: trackPixelFor("referral-qualified", referrer.email),
});
}
@@ -108,6 +142,7 @@ export class NovuService {
referrer.email,
webUrl("/dashboard/settings?tab=referral"),
),
trackPixel: trackPixelFor("referral-reward", referrer.email),
});
}

View File

@@ -15,8 +15,6 @@ export interface NovuRecipient {
email: string;
/** First name for greeting (`Merhaba {firstName}`). */
firstName?: string;
/** "en" → English template; anything else / undefined → Turkish (default). */
locale?: string;
}
export type NovuPayload = Record<string, unknown>;
@@ -28,6 +26,68 @@ const NOVU_API_URL = (process.env.NOVU_API_URL || "https://api.bildirim.semih.ai
const APP_PUBLIC_URL = (process.env.APP_PUBLIC_URL || "https://sase.tr").replace(/\/+$/, "");
const TRIGGER_TIMEOUT_MS = 10_000;
/**
* Mailbox we expose as the List-Unsubscribe mailto: target. Receives any
* "please unsubscribe me" replies — Postal has a route on `unsubscribe@sase.tr`
* (audit §9.1) forwarding to destek's SnappyMail so the team sees them.
*/
const UNSUBSCRIBE_EMAIL = process.env.UNSUBSCRIBE_EMAIL || "unsubscribe@sase.tr";
/**
* HTTPS one-click endpoint base. Defaults to `<APP_PUBLIC_URL>/api/email/unsubscribe`
* which is where UnsubscribeController lives. Empty string disables the HTTPS
* variant (mailto-only header), which is what we want until UNSUBSCRIBE_SECRET
* is configured.
*/
const UNSUBSCRIBE_URL_BASE =
process.env.UNSUBSCRIBE_URL_BASE || `${APP_PUBLIC_URL}/api/email/unsubscribe`;
/**
* HMAC secret for stateless unsubscribe tokens. Must be set in prod for the
* HTTPS variant to mint valid tokens — when unset, we ship the mailto: header
* only (still RFC-2369-compliant, satisfies Yahoo, partial credit on Gmail).
*/
const UNSUBSCRIBE_SECRET = process.env.UNSUBSCRIBE_SECRET || "";
/**
* Auth + payment flows where we MUST NOT advertise an unsubscribe link —
* privacy-proxy bots sometimes pre-fetch List-Unsubscribe URLs and we don't
* want token consumption for the verify/reset case, and we don't want to
* suppress receipt/dunning mail at all.
*/
const NO_UNSUBSCRIBE_WORKFLOWS = new Set<string>([
"email-verification",
"password-reset",
"payment-success",
"payment-failed",
]);
function buildUnsubscribeHeaders(
workflow: string,
subscriberId: string,
): Record<string, string> {
if (NO_UNSUBSCRIBE_WORKFLOWS.has(workflow)) return {};
const targets: string[] = [];
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
const token = createHmac("sha256", UNSUBSCRIBE_SECRET)
.update(`${subscriberId}|${workflow}`)
.digest("hex");
const q = new URLSearchParams({ u: subscriberId, w: workflow, t: token });
targets.push(`<${UNSUBSCRIBE_URL_BASE}?${q.toString()}>`);
}
targets.push(
`<mailto:${UNSUBSCRIBE_EMAIL}?subject=unsubscribe%3A${encodeURIComponent(workflow)}>`,
);
const headers: Record<string, string> = { "List-Unsubscribe": targets.join(", ") };
// RFC 8058 one-click — only assert when an HTTPS endpoint is wired; Gmail
// will probe the HTTPS target with POST when this header is present, so
// gate it behind both env vars being set.
if (UNSUBSCRIBE_URL_BASE && UNSUBSCRIBE_SECRET) {
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return headers;
}
/** Build an absolute URL on the public marketing site (e.g. webUrl("/dashboard")). */
export function webUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
@@ -48,10 +108,22 @@ export function formatTrDate(date: Date): string {
}).format(date);
}
/**
* Click-tracking lifetime. 30d is longer than any realistic "I'll get back
* to this welcome mail" window (template CTAs point at /dashboard, useful
* for ~weeks) and short enough that a leaked signed URL ages out before it
* becomes a replay nuisance. mailAudit.md §9.4 #18.
*/
const TRACK_URL_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Wrap a CTA target in a signed track.sase.tr click link for open/click tracking.
* HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}`)). Returns the raw
* target unchanged when no secret is configured.
* HMAC = hex(HMAC_SHA256(MAILTRACK_SECRET, `${mid}|${target}|${exp}`)). Returns
* the raw target unchanged when no secret is configured.
*
* `exp` is a unix-ms timestamp; the worker rejects clicks after that point
* with HTTP 410 even if the HMAC matches. Enough that a leaked link can't be
* replayed indefinitely.
*
* 🔒 NEVER use this for auth links (email verification / password reset) — the
* tracking redirect can consume the one-time token. Pass those URLs directly.
@@ -60,11 +132,29 @@ export function buildTrackedUrl(campaign: string, recipient: string, target: str
const secret = process.env.MAILTRACK_SECRET;
if (!secret) return target;
const mid = randomUUID();
const sig = createHmac("sha256", secret).update(`${mid}|${target}`).digest("hex");
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, s: sig });
const exp = String(Date.now() + TRACK_URL_EXPIRY_MS);
const sig = createHmac("sha256", secret).update(`${mid}|${target}|${exp}`).digest("hex");
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient, u: target, e: exp, s: sig });
return `https://track.sase.tr/c?${q.toString()}`;
}
/**
* Open-pixel URL for embedding in templates as `<img src="...">`. The pixel
* itself is a 1x1 transparent GIF; the side-effect is a row in the mailtrack
* D1 events table. Unsigned — knowing the recipient's address is the only
* "secret" and that's already on the message.
*
* Returns `null` when MAILTRACK_SECRET is unset so the caller can skip the
* payload entirely rather than shipping a pixel pointing nowhere (cleaner
* dev path; aligns with how buildTrackedUrl no-ops). mailAudit.md §9.4 #17.
*/
export function buildTrackPixelUrl(campaign: string, recipient: string): string | null {
if (!process.env.MAILTRACK_SECRET) return null;
const mid = randomUUID();
const q = new URLSearchParams({ m: mid, c: campaign, r: recipient });
return `https://track.sase.tr/o?${q.toString()}`;
}
/**
* Fire a Novu workflow. Never throws — failures are caught and reported via the
* optional logger so a notification hiccup can never break signup/payment flows.
@@ -84,16 +174,27 @@ export async function triggerNovu(
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRIGGER_TIMEOUT_MS);
// Bulk-sender compliance (Gmail/Yahoo Feb-2024) — see buildUnsubscribeHeaders.
// Auth + payment workflows opt out via NO_UNSUBSCRIBE_WORKFLOWS. Header reaches
// Postal only AFTER the host-side Novu NodemailerProvider patch is applied —
// see postal/novu-patches/apply-headers-patch.sh.
const unsubHeaders = buildUnsubscribeHeaders(name, to.subscriberId);
const body: Record<string, unknown> = { name, to, payload };
if (Object.keys(unsubHeaders).length > 0) {
body.overrides = { email: { headers: unsubHeaders } };
}
try {
const res = await fetch(`${NOVU_API_URL}/v1/events/trigger`, {
method: "POST",
headers: { Authorization: `ApiKey ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ name, to, payload }),
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
logger.error(`[novu] trigger "${name}" failed: HTTP ${res.status} ${body.slice(0, 300)}`);
const errBody = await res.text().catch(() => "");
logger.error(
`[novu] trigger "${name}" failed: HTTP ${res.status} ${errBody.slice(0, 300)}`,
);
return;
}
logger.log(`[novu] triggered "${name}" → ${to.email}`);

View File

@@ -0,0 +1,155 @@
import { Body, Controller, Get, Logger, Post, Query, Res } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Throttle } from "@nestjs/throttler";
import type { Response } from "express";
import { Public } from "../common/decorators/public.decorator";
import {
EmailPreferencesService,
OPTIONAL_WORKFLOWS,
verifyUnsubscribeToken,
} from "./email-preferences.service";
/**
* RFC 8058 one-click + manual unsubscribe endpoint.
*
* POST /api/email/unsubscribe?u=USERID&w=WORKFLOW&t=HMAC
* body: `List-Unsubscribe=One-Click` (Gmail/Yahoo bot path; must return 200
* fast). The `List-Unsubscribe-Post` header in outgoing mail tells the bot
* to send this exact POST.
*
* GET /api/email/unsubscribe?u=USERID&w=WORKFLOW&t=HMAC
* Human visit (mail client surfaced the URL as a clickable link). We mark
* the row opted-out AND render a tiny HTML confirmation page so the user
* doesn't see an empty 200.
*
* The token is an HMAC of `userId|workflow` under UNSUBSCRIBE_SECRET — see
* email-preferences.service.ts. Stateless; no DB lookup needed to validate.
* mailAudit.md §9.3 #14.
*/
@Controller("email/unsubscribe")
export class UnsubscribeController {
private readonly logger = new Logger(UnsubscribeController.name);
private readonly secret: string;
constructor(
private readonly preferences: EmailPreferencesService,
config: ConfigService,
) {
this.secret =
config.get<string>("UNSUBSCRIBE_SECRET") || process.env.UNSUBSCRIBE_SECRET || "";
if (!this.secret) {
this.logger.warn(
"UNSUBSCRIBE_SECRET is unset — all one-click requests will be rejected",
);
}
}
/** RFC 8058 one-click. Must respond 200 fast — Gmail/Yahoo timeout aggressively. */
@Post()
@Public()
// Slightly higher than user-facing throttles because mail clients sometimes
// probe the URL multiple times during inbox scan.
@Throttle({ default: { limit: 20, ttl: 600_000 } })
async oneClick(
@Query("u") userId: string,
@Query("w") workflow: string,
@Query("t") token: string,
@Body() _body: unknown,
@Res({ passthrough: true }) res: Response,
): Promise<{ ok: boolean }> {
const ok = await this.applyOptOut(userId, workflow, token, "one_click");
res.status(ok ? 200 : 400);
return { ok };
}
/**
* Human-visit path. Same validation as POST; on success returns a minimal
* HTML confirmation page (or a "link expired" page on invalid token).
*/
@Get()
@Public()
@Throttle({ default: { limit: 10, ttl: 600_000 } })
async render(
@Query("u") userId: string,
@Query("w") workflow: string,
@Query("t") token: string,
@Res() res: Response,
): Promise<void> {
const ok = await this.applyOptOut(userId, workflow, token, "manual_link");
res.status(ok ? 200 : 400).type("html").send(renderPage(ok, workflow));
}
/** Shared validation + DB update. Returns false on bad token / bad workflow. */
private async applyOptOut(
userId: string,
workflow: string,
token: string,
source: "one_click" | "manual_link",
): Promise<boolean> {
if (!userId || !workflow || !token) return false;
if (!OPTIONAL_WORKFLOWS.has(workflow)) {
this.logger.warn(`unsubscribe rejected — non-optional workflow ${workflow}`);
return false;
}
if (!verifyUnsubscribeToken(this.secret, userId, workflow, token)) {
this.logger.warn(`unsubscribe rejected — invalid token (workflow=${workflow})`);
return false;
}
try {
await this.preferences.optOut(userId, workflow, source);
this.logger.log(`opt-out: user=${userId} workflow=${workflow} source=${source}`);
return true;
} catch (err) {
this.logger.error(`unsubscribe DB error: ${String(err)}`);
return false;
}
}
}
const WORKFLOW_LABELS: Record<string, string> = {
welcome: "Hoş geldin maili",
"trial-ending": "Deneme bitiş hatırlatması",
"win-back": "Geri kazanma maili",
referral: "Davet hatırlatması",
"referral-qualified": "Davet bildirimleri",
"referral-reward": "Ödül bildirimleri",
};
/**
* Plain-HTML response — kept dependency-free (no template engine) so it works
* even when the SPA isn't reachable. Same wordmark/colours as the email
* footers so the user knows it's us.
*/
function renderPage(ok: boolean, workflow: string): string {
const label = WORKFLOW_LABELS[workflow] || workflow;
if (!ok) {
return /* html */ `<!doctype html><meta charset="utf-8">
<title>Bağlantı geçersiz — Sase.tr</title>
<body style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#f4f5f7;color:#1a1a1a;margin:0;padding:48px 16px;">
<main style="max-width:540px;margin:0 auto;background:#fff;border-radius:14px;padding:36px 32px;">
<div style="font-size:22px;font-weight:700;letter-spacing:-0.4px;">Sase.tr</div>
<h1 style="font-size:20px;margin:22px 0 14px;">Bağlantı geçersiz veya süresi dolmuş</h1>
<p style="color:#4a4a4a;line-height:1.6;">Bu abonelikten çık bağlantısı tanınmadı. Daha yeni bir e-postadaki bağlantıyı dener misin?</p>
<p style="color:#777;font-size:14px;margin-top:24px;">Yardım için <a href="mailto:destek@sase.tr" style="color:#2563eb;">destek@sase.tr</a> ile iletişime geç.</p>
</main></body>`;
}
return /* html */ `<!doctype html><meta charset="utf-8">
<title>Abonelikten çıkıldı — Sase.tr</title>
<body style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;background:#f4f5f7;color:#1a1a1a;margin:0;padding:48px 16px;">
<main style="max-width:540px;margin:0 auto;background:#fff;border-radius:14px;padding:36px 32px;">
<div style="font-size:22px;font-weight:700;letter-spacing:-0.4px;">Sase.tr</div>
<h1 style="font-size:20px;margin:22px 0 14px;">Abonelikten çıkıldı</h1>
<p style="color:#4a4a4a;line-height:1.6;">Artık <strong>${escapeHtml(label)}</strong> almayacaksın. Hesabınla ilgili önemli bilgilendirme mailleri (e-posta doğrulama, ödeme bildirimleri) gelmeye devam eder.</p>
<p style="color:#777;font-size:14px;margin-top:24px;">Fikrini değiştirirsen ayarlar &gt; bildirimler sayfasından geri açabilirsin.</p>
<p style="margin-top:22px;"><a href="https://sase.tr/dashboard/settings/notifications" style="display:inline-block;background:#111;color:#fff;text-decoration:none;padding:12px 26px;border-radius:8px;font-weight:600;">Ayarları aç</a></p>
</main></body>`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

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

@@ -6,6 +6,7 @@ import { track as trackMeta } from "@/lib/meta-pixel";
import { capture, identifyUser } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cleanModelName } from "@/lib/vehicle";
import { suggestEmailFix } from "@sase/shared";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
@@ -65,6 +66,7 @@ function RegisterPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [refCode, setRefCode] = useState(ref || "");
const [loading, setLoading] = useState(false);
@@ -291,9 +293,37 @@ function RegisterPage() {
type="email"
placeholder="ornek@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
// Hide a previously shown suggestion as soon as the user keeps
// typing — recompute on blur so we don't nag mid-typing.
if (emailSuggestion) setEmailSuggestion(null);
}}
onBlur={() => setEmailSuggestion(suggestEmailFix(email))}
required
/>
{emailSuggestion && (
<p className="text-sm text-muted-foreground">
Bunu mu demek istedin?{" "}
<button
type="button"
className="text-primary underline underline-offset-2 hover:opacity-80"
onClick={() => {
setEmail(emailSuggestion);
setEmailSuggestion(null);
// PostHog signal so we can see how often the suggestion is
// accepted vs ignored — informs whether to keep the
// dictionary growing or just trust browser-native typo hints.
capture("signup_email_typo_corrected", {
from: email,
to: emailSuggestion,
});
}}
>
{emailSuggestion}
</button>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Şifre</Label>

View File

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

View File

@@ -26,6 +26,8 @@ import {
slugify,
generateReferralCode,
normalizeEmail,
normalizeName,
suggestEmailFix,
referralRewardForCount,
referralTotalRewardDays,
// Constants
@@ -631,6 +633,114 @@ describe("normalizeEmail", () => {
});
});
describe("normalizeName (Turkish-locale title-case)", () => {
it("title-cases lowercase input", () => {
expect(normalizeName("mehmet")).toBe("Mehmet");
});
it("title-cases uppercase input", () => {
expect(normalizeName("MEHMET")).toBe("Mehmet");
});
it("handles Turkish İ → i pair correctly (locale-aware)", () => {
// `İLKER` lower-cases to `ilker` (NOT `i̇lker`) under tr locale,
// and lowercase `i` upper-cases to `İ`, not `I`.
expect(normalizeName("İLKER")).toBe("İlker");
expect(normalizeName("ilker")).toBe("İlker");
});
it("handles Turkish ı (dotless) correctly", () => {
expect(normalizeName("ALİ YILMAZ")).toBe("Ali Yılmaz");
expect(normalizeName("yılmaz")).toBe("Yılmaz");
});
it("title-cases each whitespace-separated token", () => {
expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz");
expect(normalizeName("ahmet veli mehmet")).toBe("Ahmet Veli Mehmet");
});
it("collapses internal whitespace", () => {
expect(normalizeName("ali yılmaz")).toBe("Ali Yılmaz");
expect(normalizeName(" ali\tyılmaz ")).toBe("Ali Yılmaz");
});
it("preserves diacritics", () => {
expect(normalizeName("ÖMER")).toBe("Ömer");
expect(normalizeName("ÇAĞRI")).toBe("Çağrı");
expect(normalizeName("ŞENOL")).toBe("Şenol");
expect(normalizeName("ÜLKÜ")).toBe("Ülkü");
});
it("title-cases each segment of a hyphenated name", () => {
expect(normalizeName("mehmet-ali")).toBe("Mehmet-Ali");
expect(normalizeName("ANNA-MARIA")).toBe("Anna-Maria");
});
it("returns '' for null/undefined/whitespace-only", () => {
expect(normalizeName(null)).toBe("");
expect(normalizeName(undefined)).toBe("");
expect(normalizeName("")).toBe("");
expect(normalizeName(" ")).toBe("");
});
it("is idempotent (already-canonical input is unchanged)", () => {
expect(normalizeName("Mehmet")).toBe("Mehmet");
expect(normalizeName("Ali Yılmaz")).toBe("Ali Yılmaz");
expect(normalizeName(normalizeName("MEHMET"))).toBe("Mehmet");
});
it("handles mixed-case noise (typo-tier signups)", () => {
expect(normalizeName("MEhmEt")).toBe("Mehmet");
expect(normalizeName("aLİ")).toBe("Ali");
expect(normalizeName("oto")).toBe("Oto");
expect(normalizeName("OTO")).toBe("Oto");
});
});
describe("suggestEmailFix", () => {
it("returns null for already-popular domains", () => {
expect(suggestEmailFix("user@gmail.com")).toBe(null);
expect(suggestEmailFix("user@hotmail.com")).toBe(null);
expect(suggestEmailFix("user@outlook.com.tr")).toBe(null);
});
it("catches real-world prod typos via exact-match dictionary", () => {
// these all came from prod Postal suppressions:
expect(suggestEmailFix("muratsmz61@icould.com")).toBe("muratsmz61@icloud.com");
expect(suggestEmailFix("user@gmial.com")).toBe("user@gmail.com");
expect(suggestEmailFix("user@hotmial.com")).toBe("user@hotmail.com");
expect(suggestEmailFix("user@gmail.co")).toBe("user@gmail.com");
expect(suggestEmailFix("user@yaho.com")).toBe("user@yahoo.com");
});
it("catches IDN-encoded typos (Turkish keyboard hiccups)", () => {
expect(suggestEmailFix("user@xn--gmail-bgd.com")).toBe("user@gmail.com");
expect(suggestEmailFix("user@xn--iclud-p4a.com")).toBe("user@icloud.com");
});
it("catches near-miss typos via Levenshtein ≤ 2", () => {
expect(suggestEmailFix("user@gnail.com")).toBe("user@gmail.com");
expect(suggestEmailFix("user@htmail.com")).toBe("user@hotmail.com");
});
it("returns null for plausibly legitimate non-popular domains", () => {
expect(suggestEmailFix("user@otoyedekparca.co")).toBe(null);
expect(suggestEmailFix("user@volanthastanesi.com.tr")).toBe(null);
expect(suggestEmailFix("user@karalarfiltre.com.tr")).toBe(null);
});
it("returns null for malformed input", () => {
expect(suggestEmailFix("")).toBe(null);
expect(suggestEmailFix("no-at-sign")).toBe(null);
expect(suggestEmailFix("@no-local-part.com")).toBe(null);
expect(suggestEmailFix("local-part-only@")).toBe(null);
});
it("preserves the local part", () => {
expect(suggestEmailFix("Foo.Bar+tag@gmial.com")).toBe("foo.bar+tag@gmail.com");
});
});
describe("referralRewardForCount (per-milestone, every 3 → 7d, every 5 → 14d)", () => {
it("grants nothing below the first milestone", () => {
expect(referralRewardForCount(0)).toBe(0);

View File

@@ -72,4 +72,6 @@ export {
slugify,
generateReferralCode,
normalizeEmail,
normalizeName,
suggestEmailFix,
} from "./utils/formatters.js";

View File

@@ -62,3 +62,171 @@ export function generateReferralCode(): string {
}
return code;
}
/**
* Normalises a personal name to "Sentence Case" with Turkish locale awareness.
*
* Why: Postal logs show signup names arrive in every casing — `mehmet`,
* `MEHMET`, `MEhmEt`, `İLKER`, `oto` — and we render them directly into mail
* subjects (`Sase.tr'ye hoş geldin, mehmet`). Title-casing in the auth hook
* means every downstream consumer (Novu subscriber profile, Stripe customer
* name, dashboard greeting) gets the same canonicalised string.
*
* Turkish rules that `toLowerCase()` / `toUpperCase()` get WRONG:
* - `I` ↔ `ı` (dotless), `İ` ↔ `i` (dotted) — invariant casing produces
* `i → I` which Turks read as a different letter. `toLocaleLowerCase("tr")`
* handles this correctly.
* - Other diacritics (Ç, Ğ, Ö, Ş, Ü) work fine under invariant casing but we
* use locale-aware for consistency.
*
* Behaviour:
* - Trims and collapses internal whitespace.
* - For each whitespace-separated token: first cp upper, rest lower.
* - Hyphenated names: each segment is title-cased (`mehmet-ali` →
* `Mehmet-Ali`).
* - Apostrophes and other punctuation are passed through unchanged.
* - Returns "" for null/undefined/whitespace-only input (so the caller's
* schema validation can reject it the same way as before).
*
* Examples:
* normalizeName("mehmet") → "Mehmet"
* normalizeName("MEHMET") → "Mehmet"
* normalizeName("İLKER") → "İlker"
* normalizeName("ali yılmaz")→ "Ali Yılmaz"
* normalizeName("ÖMER") → "Ömer"
* normalizeName("ahmet-ali") → "Ahmet-Ali"
*/
export function normalizeName(name: string | null | undefined): string {
if (name == null) return "";
const trimmed = name.replace(/\s+/g, " ").trim();
if (trimmed === "") return "";
return trimmed
.split(" ")
.map(titleCaseToken)
.join(" ");
}
/**
* Suggests a corrected e-mail when the domain looks like a typo of a popular
* provider. Returns `null` when the address looks fine.
*
* Why: prod Postal logs show a steady ~6% typo rate at signup — `icould.com`,
* `gmial.com`, `hotmial.com`, `gmail.co`, plus IDN-encoded variants like
* `xn--gmail-bgd.com` (Turkish keyboard "ı" → punycoded). These addresses
* hard-bounce, the user never gets the verification mail, and Postal
* suppresses the recipient. Catching it at signup avoids that whole loop.
*
* Strategy:
* 1. Lowercase + trim, then split local + domain.
* 2. Exact-match a typo dictionary first (cheapest, catches `gmial.com` →
* `gmail.com` and the punycoded IDNs we've actually seen in prod).
* 3. Fall back to Levenshtein distance ≤ 2 against a popular-provider list
* (catches longer-tail misses like `gnail.com` or `htmail.com`).
* 4. Refuse to suggest when the input domain is identical to a popular one
* (else `gmail.com` → suggest `gmail.com` self-suggest).
*
* Returns the FULL corrected address so the caller can swap it directly.
*/
const POPULAR_DOMAINS = [
"gmail.com",
"hotmail.com",
"outlook.com",
"yahoo.com",
"icloud.com",
"msn.com",
"live.com",
"yandex.com",
"yandex.com.tr",
"outlook.com.tr",
"hotmail.com.tr",
];
const EXACT_TYPOS: Record<string, string> = {
// Real punycoded typos we've seen in prod (Turkish keyboard quirks):
"xn--gmail-bgd.com": "gmail.com",
"xn--hotmail-cie.com": "hotmail.com",
"xn--iclud-p4a.com": "icloud.com",
// Common Latin-letter typos:
"gmial.com": "gmail.com",
"gnail.com": "gmail.com",
"gmail.co": "gmail.com",
"gmail.cm": "gmail.com",
"gmaill.com": "gmail.com",
"gmal.com": "gmail.com",
"gamil.com": "gmail.com",
"hotmial.com": "hotmail.com",
"hotmal.com": "hotmail.com",
"hotnail.com": "hotmail.com",
"hotmail.co": "hotmail.com",
"hotmail.cm": "hotmail.com",
"outlok.com": "outlook.com",
"outloook.com": "outlook.com",
"outloko.com": "outlook.com",
"yaho.com": "yahoo.com",
"yahooo.com": "yahoo.com",
"yahoo.co": "yahoo.com",
"icould.com": "icloud.com",
"iclod.com": "icloud.com",
"iclud.com": "icloud.com",
};
export function suggestEmailFix(email: string): string | null {
const trimmed = (email || "").trim().toLowerCase();
const at = trimmed.lastIndexOf("@");
if (at < 1 || at >= trimmed.length - 1) return null;
const local = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
if (POPULAR_DOMAINS.includes(domain)) return null;
// Exact-match dictionary first (cheapest).
const exact = EXACT_TYPOS[domain];
if (exact) return `${local}@${exact}`;
// Levenshtein distance ≤ 2 against popular list.
let best: { d: string; dist: number } | null = null;
for (const candidate of POPULAR_DOMAINS) {
// Quick length-prefilter: distance ≥ |len diff|.
if (Math.abs(candidate.length - domain.length) > 2) continue;
const dist = levenshtein(domain, candidate);
if (dist <= 2 && (best === null || dist < best.dist)) {
best = { d: candidate, dist };
}
}
if (best) return `${local}@${best.d}`;
return null;
}
/** Standard Levenshtein — small string, allocation-cheap. */
function levenshtein(a: string, b: string): number {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = a.length;
const n = b.length;
let prev = new Array(n + 1).fill(0);
let curr = new Array(n + 1).fill(0);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++) {
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
[prev, curr] = [curr, prev];
}
return prev[n];
}
/** Title-case a single whitespace-free token, hyphen-aware. */
function titleCaseToken(token: string): string {
if (token === "") return token;
// Hyphen-separated names — each part gets its own title-case so
// `mehmet-ali` → `Mehmet-Ali`, not `Mehmet-ali`.
if (token.includes("-")) {
return token.split("-").map(titleCaseToken).join("-");
}
// Use array-of-codepoints to avoid splitting surrogate pairs mid-character
// (Turkish letters are BMP, but defending against accidental emoji etc.).
const chars = [...token.toLocaleLowerCase("tr-TR")];
if (chars.length === 0) return "";
chars[0] = chars[0].toLocaleUpperCase("tr-TR");
return chars.join("");
}

View File

@@ -0,0 +1,26 @@
-- Historical seed (generated 2026-06-04 from postal-server-1)
-- Run AFTER migration 0012, BEFORE tomorrow's lifecycle cron.
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('a988f621-f3b4-4086-aee8-38583bc0aaca', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('107ed4f1-2bb1-4730-9f71-1a33c4d75504', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('664cd1fa-4ce3-4f2b-9184-bbaca0f8b020', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('d03d711a-6235-4f45-ae80-65bf0b815ce0', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('844a47b8-640d-42a3-910c-68a23e14a8f6', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('6e4c13b2-1e35-4e9c-a737-55dd6075b130', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('ee0b8dfa-ae02-41b8-9886-3247a0a2c471', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('08baff23-9754-4762-a665-ce8251b81333', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9d966aa6-8033-4c67-98bb-e84c00c68157', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9faea025-6ed2-4897-a503-7d5697d6ab7e', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('2f70a2b6-e3df-4419-b688-7488a1a69515', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9112fa82-61ee-4b89-b815-e50abb78544f', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('9c41b4c4-3b67-401d-9563-ea9d97763513', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('16ed8cb3-9bdf-4918-a1c0-253008d112e0', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('0e69e85f-3cfb-4d2e-abec-de086bd6e592', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fd469c94-377a-4148-a280-a1cf361b7b6d', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('419b51f1-14c8-4ef0-a221-f6ffff93f6b1', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('fa2bd88e-c256-4a7f-8ee6-51e06a785c7a', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('5b1eda7f-e7bd-4238-9964-9d3707eb2e39', 'trial-ending') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('e457416c-f406-47ed-8e23-4a832c2fc965', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('89607516-7061-432e-bd40-971ccbf4e02b', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('15390f23-6d87-4be1-a644-df7602ee58bc', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('21191b23-b327-4239-a0fc-18974b97e35c', 'win-back') ON CONFLICT DO NOTHING;
INSERT INTO lifecycle_email_sent (user_id, workflow) VALUES ('83d9628a-faa5-40d3-a75c-8ae12629ecdf', 'win-back') ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,75 @@
/**
* One-shot backfill: title-case existing `users.name` rows.
*
* After this lands, every NEW signup gets canonicalised in the better-auth
* `user.create.before` hook (see apps/api/src/auth/auth.ts). Existing rows
* pre-date that hook and still carry whatever the user typed at signup:
* `mehmet`, `MEHMET`, `İLKER`, `OTO`, …. This script applies the same
* `normalizeName()` Turkish-locale-aware title-case to historical rows so
* mail subjects (`Sase.tr'ye hoş geldin, mehmet` → `, Mehmet`) and dashboard
* greetings render consistently.
*
* Safe to re-run: the UPDATE is gated on `name <> normalized`, so already-
* canonical rows aren't touched.
*
* Usage:
* pnpm tsx scripts/backfill-user-names.ts --dry-run # preview only
* pnpm tsx scripts/backfill-user-names.ts # write
*
* Run against BOTH prod (sase) and dev (sase_dev) DBs separately by pointing
* DATABASE_URL at each. mailAudit.md §9.3 #9.
*/
import * as path from "node:path";
import * as dotenv from "dotenv";
import postgres from "postgres";
import { normalizeName } from "@sase/shared";
dotenv.config({ path: path.join(__dirname, "../apps/api/.env") });
const argv = process.argv.slice(2);
const dryRun = argv.includes("--dry-run");
async function main() {
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL is not set");
process.exit(1);
}
const sql = postgres(process.env.DATABASE_URL, { max: 1 });
const rows = await sql<{ id: string; name: string }[]>`
SELECT id, name FROM users WHERE name IS NOT NULL AND name <> ''
`;
console.log(`[backfill] scanned ${rows.length} users`);
let changed = 0;
let unchanged = 0;
const samples: Array<{ before: string; after: string }> = [];
for (const r of rows) {
const norm = normalizeName(r.name);
if (norm === r.name) {
unchanged++;
continue;
}
if (samples.length < 15) samples.push({ before: r.name, after: norm });
if (!dryRun) {
await sql`UPDATE users SET name = ${norm}, updated_at = NOW() WHERE id = ${r.id}`;
}
changed++;
}
console.log(`[backfill] ${changed} changed, ${unchanged} already canonical`);
if (samples.length) {
console.log("[backfill] sample diffs:");
for (const s of samples) console.log(` '${s.before}' → '${s.after}'`);
}
if (dryRun) console.log("[backfill] DRY RUN — no writes");
await sql.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});