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

Reviewed-on: #106
This commit was merged in pull request #106.
This commit is contained in:
2026-06-04 20:59:34 +00:00
5 changed files with 248 additions and 107 deletions

View File

@@ -25,6 +25,9 @@ const p = svc as unknown as {
model: string;
year: number;
productionDate: string | null;
series: string | null;
bodyType: string | null;
engineCode: string | null;
catalogInfo?: { mainGroupsPath?: string };
};
};
@@ -115,3 +118,39 @@ describe("PL24Service.parseVehicleResponse — p5vwag shape still works (regress
expect(r.catalogInfo?.mainGroupsPath).toContain("/p5vwag/");
});
});
describe("PL24Service.parseVehicleResponse — p5bmw (no prNr; chassis in 'Seri')", () => {
// Real /p5bmw shape (captured live 2026-06-04): no prNr segment; the plain "Model" is only
// the trim ("520i"), with chassis/generation in "Seri" ("5 G30") and body in "Karoseri".
const bmwData = {
resultStatus: "VEHICLE_IDENTIFIED",
description: "5' G30- Limousine- CARBONSCHWARZ METALLIC (416)",
link: { path: "/p5bmw/extern/groups/vin/maingroups?vin=WBAJA" },
segments: {
vinfoBasic: {
records: [
{ values: { description: "Model tanimi", value: "5' G30" } },
{ values: { description: "Üretim tarihi", value: "27.09.2019" } },
{ values: { description: "Tahrik", value: "RWD" } },
{ values: { description: "Model", value: "520i" } },
{ values: { description: "Seri", value: "5 G30" } },
{ values: { description: "Karoseri", value: "Limousine" } },
{ values: { description: "Motor kodu", value: "B48B16M0" } },
],
},
},
};
it("folds the chassis ('Seri' minus the redundant line token) into the model", () => {
const r = p.parseVehicleResponse("WBAJA3100LCD26756", bmwData as never, "bmw_parts");
expect(r.model).toBe("520i G30"); // "520i" + chassis "G30" (line "5" dropped — implied by "520i")
expect(r.series).toBe("5 G30"); // series keeps the full "Seri"
});
it("derives year from production date, body from 'Karoseri', engine from 'Motor kodu'", () => {
const r = p.parseVehicleResponse("WBAJA3100LCD26756", bmwData as never, "bmw_parts");
expect(r.year).toBe(2019);
expect(r.bodyType).toBe("Limousine");
expect(r.engineCode).toBe("B48B16M0");
});
});

View File

@@ -1052,9 +1052,12 @@ export class PL24Service {
// Transmission code: vinfoBasic "Şanzıman kodu" / "Transmission code"
const transmissionCode = lookup("şanzıman_kodu", "sanzıman_kodu", "transmission_code");
// Build body type from prNr K8* (Kaporta formları)
// Build body type from prNr K8* (Kaporta formları); brands without a prNr segment
// (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine").
const bodyType =
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || null;
Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] ||
lookup("karoseri", "body", "body_type") ||
null;
// Engine description from prNr D3* (Motor nitelikleri)
const engineDesc =
@@ -1064,19 +1067,31 @@ export class PL24Service {
const transmissionDesc =
Object.entries(prNrByCode).find(([code]) => code.startsWith("G0"))?.[1] || null;
// Drive type from prNr 1X* (Tahrik türü)
// Drive type from prNr 1X* (Tahrik türü); BMW carries it in vinfoBasic "Tahrik" (RWD/xDrive).
const driveType =
Object.entries(prNrByCode).find(([code]) => code.startsWith("1X"))?.[1] ||
lookup("aks_tahrigi_tanimi", "axle_drive");
lookup("tahrik", "aks_tahrigi_tanimi", "axle_drive");
// Friendly model. p5fiat uses "Model bilgisi" ("Panda POP 1.2 …"); most P5 backends use
// "Model". BMW's "Model" is only the trim ("520i") with the chassis/generation in "Seri"
// ("5 G30") — fold the generation in so parts aren't ambiguous across chassis (E60/F10/G30).
const baseModel =
lookup("model_bilgisi", "model")?.trim() ||
(data.description as string)?.split(" - ")[0]?.trim() ||
"";
// "Seri" is "{line} {chassis} [{variant}]" ("5 G30", "X3 G01", "5 E60 MUE"). The trim already
// implies the line ("520i"→5, "X3 sDrive20i"→X3), so drop the leading line token and append
// only the chassis(+variant) → "520i G30", "X3 sDrive20i G01" (avoids a redundant "X3 X3").
const seri = lookup("seri", "model_tanimi");
const chassis = seri ? seri.replace(/^\S+\s+/, "").trim() : null;
const model =
chassis && baseModel && !baseModel.toLowerCase().includes(chassis.toLowerCase())
? `${baseModel} ${chassis}`
: baseModel;
return {
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
// 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() ||
"",
model,
// 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:
@@ -1087,7 +1102,7 @@ export class PL24Service {
) ||
extractModelYear(vin) ||
0,
series: lookup("satis_tipi", "sales_type"),
series: lookup("seri", "satis_tipi", "sales_type"),
bodyType,
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
engineType: engineDesc,

View File

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

View File

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

View File

@@ -54,50 +54,38 @@ const TAB_ITEMS = [
] 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.
* UI categories rendered by the Notifications tab. Each maps to a bundle of
* underlying workflows on the server — see
* `apps/api/src/notifications/email-preferences.service.ts
* NOTIFICATION_CATEGORIES`. We render two switches, not six, because users
* care about "e-mail vs mobile" not "did the day-3 referral mail fire".
*
* The API also returns these (`label`, `description`) so the server is the
* source of truth for copy; the static fallback here just avoids a flash of
* untitled rows before the GET completes.
*/
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.",
type NotificationCategoryKey = "email_marketing" | "mobile_push";
const FALLBACK_CATEGORY_COPY: Record<
NotificationCategoryKey,
{ label: string; description: string }
> = {
email_marketing: {
label: "E-posta bildirimleri",
description:
"Hoş geldin, deneme bitişi, davet hatırlatması, ödül ve geri kazanma mailleri. Hesap güvenliği ve ödeme bildirimleri her zaman gelir.",
},
{
workflow: "trial-ending",
title: "Deneme bitiş hatırlatması",
description: "Deneme süresinin son birkaç gününde gönderilen yükseltme önerisi.",
mobile_push: {
label: "Mobil bildirim",
description:
"Mobil uygulama push bildirimleri. Mobil uygulama yayınlandığında bu tercih kullanılır.",
},
{
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;
category: NotificationCategoryKey;
label: string;
description: string;
optedOut: boolean;
}
@@ -685,7 +673,7 @@ export function SettingsContent({
function NotificationsCard() {
const { t } = useTranslation();
const [prefs, setPrefs] = useState<NotificationPref[] | null>(null);
const [pending, setPending] = useState<Set<string>>(new Set());
const [pending, setPending] = useState<Set<NotificationCategoryKey>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -703,37 +691,44 @@ function NotificationsCard() {
};
}, []);
async function toggle(workflow: string, currentlyOptedOut: boolean) {
const next = !currentlyOptedOut;
async function toggle(category: NotificationCategoryKey, currentlyOptedOut: boolean) {
const nextOptedOut = !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,
cur
? cur.map((p) => (p.category === category ? { ...p, optedOut: nextOptedOut } : p))
: cur,
);
setPending((s) => new Set(s).add(workflow));
setPending((s) => new Set(s).add(category));
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ı.");
await api.post("/email/preferences", { category, optedOut: nextOptedOut });
capture(nextOptedOut ? "notifications_category_opted_out" : "notifications_category_opted_in", {
category,
});
toast.success(nextOptedOut ? "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,
p.category === category ? { ...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;
const ns = new Set(s);
ns.delete(category);
return ns;
});
}
}
// Show two rows regardless of API state: keys are stable, so we render the
// skeleton in their slots until the GET resolves.
const categoryKeys: NotificationCategoryKey[] = ["email_marketing", "mobile_push"];
return (
<Card>
<CardHeader>
@@ -743,34 +738,35 @@ function NotificationsCard() {
<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);
categoryKeys.map((key) => {
const row = prefs?.find((p) => p.category === key);
const isLoading = prefs === null;
if (isLoading) {
return <Skeleton key={key} className="h-20 w-full" />;
}
const fallback = FALLBACK_CATEGORY_COPY[key];
const label = row?.label ?? fallback.label;
const description = row?.description ?? fallback.description;
const optedOut = row?.optedOut ?? false;
const isPending = pending.has(w.workflow);
const isPending = pending.has(key);
return (
<div
key={w.workflow}
key={key}
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>
<p className="text-sm font-medium">{label}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button
type="button"
variant={optedOut ? "outline" : "default"}
size="sm"
disabled={isPending}
onClick={() => toggle(w.workflow, optedOut)}
onClick={() => toggle(key, optedOut)}
aria-pressed={!optedOut}
aria-label={`${w.title}: ${optedOut ? "kapalı" : "açık"}`}
aria-label={`${label}: ${optedOut ? "kapalı" : "açık"}`}
>
{isPending ? "…" : optedOut ? "Kapalı" : "Açık"}
</Button>