From aaa96d82e30ea4eeb4664602e8347fa4f6981fbb Mon Sep 17 00:00:00 2001 From: "Claude (notifications categorize)" Date: Thu, 4 Jun 2026 23:45:35 +0300 Subject: [PATCH 1/3] refactor(notifications): collapse 6 per-workflow toggles into 2 categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Bildirimler had grown a 6-row list (welcome / trial-ending / referral / referral-qualified / referral-reward / win-back) that read like an internal cron schedule rather than a user choice. Users care about e-mail vs mobile, not which Novu trigger fires the day-3 nudge. Replaces the per-workflow UI with two switches: • E-posta bildirimleri — bundles all six marketing/lifecycle workflows above, off = mute all • Mobil bildirim — placeholder for the not-yet-shipped mobile app push channel; the preference is stored so it Just Works when push ships Auth + payment mail remain unaffected — the server-side OPTIONAL_WORKFLOWS filter is still the canonical opt-out gate. API --- Same path (`/api/email/preferences`), category-shaped payload: GET → `[{category, label, description, optedOut}, …]` (two rows) POST → body `{category, optedOut}` (toggles every workflow in the bundle) UnsubscribeController is untouched — one-click List-Unsubscribe URLs in mail still address a single workflow (we don't want clicking the welcome- mail unsub link to also kill the trial-ending nudge a week later). Service ------- New `NOTIFICATION_CATEGORIES` const + `getCategoryState()` / `setCategoryState()` on EmailPreferencesService. `mobile_push` added to OPTIONAL_WORKFLOWS so the same row-presence guard works for it. UI -- NotificationsCard renders two rows (or two skeletons) — keys are stable so the skeletons match the final layout. Category copy comes from the API; static FALLBACK_CATEGORY_COPY avoids a flash of untitled rows before GET resolves. PostHog events renamed from `email_workflow_opted_in/out` to `notifications_category_opted_in/out` since the per-workflow event was never going to be useful. Co-Authored-By: Claude Opus 4.7 --- .../email-preferences.controller.ts | 63 +++++---- .../email-preferences.service.ts | 88 ++++++++++++ .../components/settings/settings-content.tsx | 128 +++++++++--------- 3 files changed, 183 insertions(+), 96 deletions(-) diff --git a/apps/api/src/notifications/email-preferences.controller.ts b/apps/api/src/notifications/email-preferences.controller.ts index da02418..4c2866c 100644 --- a/apps/api/src/notifications/email-preferences.controller.ts +++ b/apps/api/src/notifications/email-preferences.controller.ts @@ -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> { - const workflows = Array.from(OPTIONAL_WORKFLOWS); - const optedOutFlags = await Promise.all( - workflows.map((w) => this.preferences.isOptedOut(user.id, w)), + ): Promise> { + 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 }; } } diff --git a/apps/api/src/notifications/email-preferences.service.ts b/apps/api/src/notifications/email-preferences.service.ts index 542cdd6..55bf98f 100644 --- a/apps/api/src/notifications/email-preferences.service.ts +++ b/apps/api/src/notifications/email-preferences.service.ts @@ -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([ "welcome", @@ -17,8 +21,54 @@ export const OPTIONAL_WORKFLOWS = new Set([ "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 { + 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 { + 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); + } + } } diff --git a/apps/web/src/components/settings/settings-content.tsx b/apps/web/src/components/settings/settings-content.tsx index c990b33..6b940ab 100644 --- a/apps/web/src/components/settings/settings-content.tsx +++ b/apps/web/src/components/settings/settings-content.tsx @@ -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(null); - const [pending, setPending] = useState>(new Set()); + const [pending, setPending] = useState>(new Set()); const [error, setError] = useState(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 ( @@ -743,34 +738,35 @@ function NotificationsCard() { {error ? (

{error}

- ) : prefs === null ? ( -
- {NOTIFICATION_WORKFLOWS.map((w) => ( - - ))} -
) : ( - 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 ; + } + 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 (
-

{w.title}

-

{w.description}

+

{label}

+

{description}

From d3ff19ad6a5eb3f1591fc187bcea5e68e097805b Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Thu, 4 Jun 2026 23:45:51 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(pl24):=20enrich=20BMW=20decode=20?= =?UTF-8?q?=E2=80=94=20chassis/generation,=20body,=20drive=20from=20vinfoB?= =?UTF-8?q?asic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BMW (p5bmw) decode was weak: model was just the trim ("520i") with no chassis/generation, body_type empty, because BMW has NO prNr segment and keeps that data in distinct vinfoBasic labels the shared parser ignored. Live-verified fields: "Seri"="5 G30" (chassis), "Karoseri"="Limousine" (body), "Tahrik"="RWD". - model: fold the generation ("Seri"/"Model tanimi") into the model when present and not already included → "520i 5 G30" (disambiguates E60/F10/G30 for parts). - bodyType: fall back to vinfoBasic "Karoseri" when there's no prNr K8*. - series: read "Seri"; driveType: read "Tahrik". Year already comes from "Üretim tarihi" (P5 year fallback). VW/Audi (prNr) and Mercedes ("Piyasa adı", no seri/karoseri) verified unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../integrations/pl24/pl24.service.spec.ts | 39 +++++++++++++++++++ .../api/src/integrations/pl24/pl24.service.ts | 33 ++++++++++------ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/apps/api/src/integrations/pl24/pl24.service.spec.ts b/apps/api/src/integrations/pl24/pl24.service.spec.ts index 0985a50..79b7b2a 100644 --- a/apps/api/src/integrations/pl24/pl24.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24.service.spec.ts @@ -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/generation ('Seri') into the model so it isn't ambiguous", () => { + const r = p.parseVehicleResponse("WBAJA3100LCD26756", bmwData as never, "bmw_parts"); + expect(r.model).toBe("520i 5 G30"); + expect(r.series).toBe("5 G30"); + }); + + 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"); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index 0ae1f8c..40e61f7 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -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,27 @@ 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() || + ""; + const generation = lookup("seri", "model_tanimi"); + const model = + generation && baseModel && !baseModel.toLowerCase().includes(generation.toLowerCase()) + ? `${baseModel} ${generation}` + : 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 +1098,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, From 94faa6cdcd1a5d34c3b9608ab6293a7acfa177b6 Mon Sep 17 00:00:00 2001 From: Semih Yesilyurt Date: Thu, 4 Jun 2026 23:55:22 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(pl24):=20cleaner=20BMW=20model=20?= =?UTF-8?q?=E2=80=94=20append=20chassis=20only,=20drop=20redundant=20line?= =?UTF-8?q?=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Seri" is "{line} {chassis} [{variant}]" and the trim already implies the line ("520i"→5, "X3 sDrive20i"→X3), so append only the chassis(+variant): "520i G30", "X3 sDrive20i G01" (was "520i 5 G30" / the redundant "X3 sDrive20i X3 G01"). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/integrations/pl24/pl24.service.spec.ts | 6 +++--- apps/api/src/integrations/pl24/pl24.service.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/api/src/integrations/pl24/pl24.service.spec.ts b/apps/api/src/integrations/pl24/pl24.service.spec.ts index 79b7b2a..5d4e2c1 100644 --- a/apps/api/src/integrations/pl24/pl24.service.spec.ts +++ b/apps/api/src/integrations/pl24/pl24.service.spec.ts @@ -141,10 +141,10 @@ describe("PL24Service.parseVehicleResponse — p5bmw (no prNr; chassis in 'Seri' }, }; - it("folds the chassis/generation ('Seri') into the model so it isn't ambiguous", () => { + 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 5 G30"); - expect(r.series).toBe("5 G30"); + 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'", () => { diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index 40e61f7..a32a5f3 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -1079,10 +1079,14 @@ export class PL24Service { lookup("model_bilgisi", "model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || ""; - const generation = lookup("seri", "model_tanimi"); + // "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 = - generation && baseModel && !baseModel.toLowerCase().includes(generation.toLowerCase()) - ? `${baseModel} ${generation}` + chassis && baseModel && !baseModel.toLowerCase().includes(chassis.toLowerCase()) + ? `${baseModel} ${chassis}` : baseModel; return {