diff --git a/apps/api/drizzle/0011_email_preferences.sql b/apps/api/drizzle/0011_email_preferences.sql new file mode 100644 index 0000000..db034a8 --- /dev/null +++ b/apps/api/drizzle/0011_email_preferences.sql @@ -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"); diff --git a/apps/api/drizzle/0012_lifecycle_email_sent.sql b/apps/api/drizzle/0012_lifecycle_email_sent.sql new file mode 100644 index 0000000..25ee871 --- /dev/null +++ b/apps/api/drizzle/0012_lifecycle_email_sent.sql @@ -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"); diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index e806ffe..4352627 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/api/src/auth/auth.ts b/apps/api/src/auth/auth.ts index 4f62b6d..39bd123 100644 --- a/apps/api/src/auth/auth.ts +++ b/apps/api/src/auth/auth.ts @@ -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), }, }; diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index e38ffbe..534616f 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -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: { diff --git a/apps/api/src/database/schema/core.ts b/apps/api/src/database/schema/core.ts index f21f0eb..a0c3717 100644 --- a/apps/api/src/database/schema/core.ts +++ b/apps/api/src/database/schema/core.ts @@ -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", diff --git a/apps/api/src/email/email.service.ts b/apps/api/src/email/email.service.ts index b44eefb..208b92f 100644 --- a/apps/api/src/email/email.service.ts +++ b/apps/api/src/email/email.service.ts @@ -26,7 +26,11 @@ export class EmailService { constructor(private configService: ConfigService) { this.postalApiUrl = configService.get("email.postalApiUrl"); this.postalApiKey = configService.get("email.postalApiKey"); - this.fromAddress = configService.get("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("email.fromAddress") || "destek@sase.tr"; this.fromName = configService.get("email.fromName") || "Sase.tr"; } diff --git a/apps/api/src/integrations/pl24/pl24.service.spec.ts b/apps/api/src/integrations/pl24/pl24.service.spec.ts new file mode 100644 index 0000000..0985a50 --- /dev/null +++ b/apps/api/src/integrations/pl24/pl24.service.spec.ts @@ -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, + 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/"); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index fe69449..0ae1f8c 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -997,16 +997,24 @@ export class PL24Service { serviceName: string, ): Omit { const segments = - (data.segments as Record }> }>) || - {}; + (data.segments as Record< + string, + { records?: Array<{ values?: Record }> } + >) || {}; const vinfoRecords = segments.vinfoBasic?.records || []; const vehicleData: Record = {}; 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: