diff --git a/apps/api/prisma/migrations/20261007120000_migration_prep_additive/migration.sql b/apps/api/prisma/migrations/20261007120000_migration_prep_additive/migration.sql new file mode 100644 index 0000000..ff39dc5 --- /dev/null +++ b/apps/api/prisma/migrations/20261007120000_migration_prep_additive/migration.sql @@ -0,0 +1,210 @@ +-- ============================================================================ +-- Preparation for the legacy data migration — PURELY ADDITIVE. +-- +-- Owner decisions of 2026-10-06 and 2026-10-07, recorded in +-- docs/DATA-MIGRATION-MAP.md §5.2, §5.5, §10, §10.4, §10.5.5. +-- +-- Nothing here drops, renames or retypes an existing column. Every statement +-- either removes a constraint or adds a new column/table, so code that works +-- today keeps working. Verified NOT touched: +-- * Customer.accounting{Gold,Rial}Balance / accountingBalanceAt (D27) +-- * Product.accountingStock{Weight,Count} / accountingStockAt (D27) +-- * ProductVariant.imageId (PROTECTED.md §B) +-- * AccountingLink / AccountingOutbox / AccountingIssue +-- * SmsMessage and the SMS module +-- These are target-only capabilities under PROTECTED.md §G ("a keep-out +-- list"), and the owner confirmed on 2026-10-06 that their data is real. +-- +-- D15: idempotent (IF NOT EXISTS / duplicate_object guards) because this runs +-- under BOTH ledgers — the Prisma CLI in development and the app's own runner +-- (src/prisma/migrate.ts) in the deploy container, which wraps each file in a +-- transaction. The runner's splitSqlStatements() handles $$-quoted blocks +-- (migrate.ts:83-91), so the DO blocks below are split correctly. +-- ============================================================================ + + +-- ---------------------------------------------------------------------------- +-- 1. Customer.nationalCode becomes OPTIONAL +-- +-- Source reality (docs/migration-precheck.sql, run 2026-10-06 on the live +-- `oapi` MySQL schema): empty_ncode = 1595 of n_customers = 1595. NOT ONE of +-- the 1,616 legacy customers has a national code. dup_ncode_groups = 0. +-- +-- Owner ruling 2026-10-07: "ساختن کد ملی فیک تخلف دیتایی است و مانع از +-- احراز هویت‌های قانونی بعدی می‌شود" — fabricating one is a data violation, so +-- the column must be nullable. Without this the customer migration cannot +-- insert a single row. +-- +-- This also RETIRES three existing workarounds that only exist because the +-- column is required. They write the MOBILE NUMBER into the national-code +-- field, i.e. they store wrong data today: +-- * admin-customers.service.ts:625 nationalCode: mobile +-- comment: "schema requires it and it is unique; singleCustomer.vue +-- collects no ncode" +-- * admin-customers.service.ts:685 nationalCode: nationalCode || mobile +-- * customer-auth.service.ts:258 ... || admin.mobile +-- Removing those fallbacks is a SERVICE-LAYER change, deliberately NOT done +-- here: this migration only makes it possible. Until they are removed the +-- behaviour is exactly as it is today. +-- +-- The UNIQUE index "Customer_nationalCode_key" (first/migration.sql:304) is +-- left in place. In PostgreSQL NULLs are not equal to each other, so any +-- number of rows may hold NULL under a unique index — which is precisely why +-- the migration tool must write NULL and never the empty string: '' would +-- collide on the second row (see §10.4). +-- +-- DROP NOT NULL takes only a brief ACCESS EXCLUSIVE lock on the catalog; it +-- does not rewrite the table and does not scan the data. +ALTER TABLE "Customer" ALTER COLUMN "nationalCode" DROP NOT NULL; + +COMMENT ON COLUMN "Customer"."nationalCode" IS + 'Legacy customers.ncode. OPTIONAL: no legacy customer has one (1595/1595 empty as of 2026-10-06), and fabricating a value is a data violation (owner, 2026-10-07). Still UNIQUE — write NULL, never '''', because NULLs do not collide under a unique index but empty strings do.'; + + +-- ---------------------------------------------------------------------------- +-- 2. Customer.birthDate — new, nullable, unread until backfilled +-- +-- Legacy `customers.birth_date timestamp NULL` (oapi dump line 484), populated +-- on 917 of 1,595 live rows (~57%). Owner ruling 2026-10-07: migrate it — +-- "نرخ پرشدگی بالای ۵۵٪ است و داده برای کمپین‌های وفاداری و پیامکی کاربرد +-- مستقیم دارد". +-- +-- Two sibling columns were deliberately NOT ported, same ruling: +-- * marriage_date (5 rows) — "۵ ردیف توجیه معماری ندارد" +-- * token_mobile (99 rows) — "توکن‌ها اغلب منقضی شده‌اند · زباله دیتایی" +-- +-- ⚠ THE COLUMN IS CREATED EMPTY ON PURPOSE. Do not backfill it until the +-- calendar question is settled (§5.9): legacy stores some dates as JALALI +-- STRINGS elsewhere (cashes.recive_date is varchar(255), 88 of 89 values +-- starting "14…"), so birth_date may hold Jalali years mis-entered into a +-- timestamp. Copying those unchanged would silently produce 917 WRONG +-- birthdays, which for an SMS campaign is worse than having none. +-- docs/migration-precheck-3.sql answers this. +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "birthDate" TIMESTAMP(3); + +COMMENT ON COLUMN "Customer"."birthDate" IS + 'Legacy customers.birth_date (917 of 1595 rows populated). Created empty: verify Jalali-vs-Gregorian with docs/migration-precheck-3.sql BEFORE backfilling — legacy stores dates as Jalali strings in other tables.'; + + +-- ---------------------------------------------------------------------------- +-- 3. Rejection trail for document verification +-- +-- Legacy has THREE states per verification step, not two +-- (MajorCustomerController.php:106-135): 1 = awaiting review, 2 = approved, +-- 3 = REJECTED. The target's VerificationStep enum has no rejected state, so +-- 14 already-rejected customers had nowhere to go. +-- +-- Owner ruling 2026-10-07: mapping them to "awaiting" is "کاملاً مردود" — +-- it would put closed cases back in the review queue and create duplicate +-- work. REJECTED is added to the enum (statement 4 below) and these two +-- columns carry the reason. +-- +-- Legacy records NO reason: all 49 `customers` columns were checked and none +-- is a reason/note/description field, and on rejection the server sends only +-- a FIXED SMS ("اطلاعات شما تایید نشده است. لطفا با پیشتیبانی تماس حاصل +-- فرمایید") with no cause stored anywhere. So for those 14 rows the reason is +-- unrecoverable; the owner asked the tool to write an explicit sentinel rather +-- than NULL, so support knows WHY it is blank: +-- رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده +-- That text is written by the migration tool, not by this migration, and only +-- WHERE verificationRejectionReason IS NULL so a re-run can never overwrite a +-- real reason entered in the new system (§5.5). +-- +-- verificationRejectedAt stays NULL for the 14 legacy rows: legacy did not +-- record a rejection timestamp either. +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectionReason" TEXT; +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "verificationRejectedAt" TIMESTAMP(3); + +COMMENT ON COLUMN "Customer"."verificationRejectionReason" IS + 'Why document verification was rejected. Legacy stored no reason (only a fixed SMS), so rows migrated from henza carry the sentinel «رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده». Write only WHERE this IS NULL so a re-run never overwrites a real reason.'; + +COMMENT ON COLUMN "Customer"."verificationRejectedAt" IS + 'When verification was rejected. NULL for rows migrated from henza — legacy did not record a rejection timestamp.'; + + +-- ---------------------------------------------------------------------------- +-- 4. VerificationStep gains REJECTED +-- +-- ADD VALUE is additive: existing rows and existing queries are unaffected. +-- IF NOT EXISTS makes a re-run a no-op. +-- +-- ⚠ CONSUMER AUDIT STILL OWED. Every read of VerificationStep must be checked +-- for a Persian label / switch that does not yet know this value, or the admin +-- panel will render a blank cell. Flagged in §10.5.5 as a separate task; it is +-- a display gap, not a data risk, and nothing writes REJECTED until the +-- migration tool runs. +-- +-- Postgres note: before v12 ADD VALUE could not run inside a transaction, and +-- the app's own runner wraps each file in one (migrate.ts:198). This project +-- targets a modern Postgres where that restriction is lifted. If the deploy +-- container ever errors with "ALTER TYPE ... ADD VALUE cannot be executed +-- inside a transaction block", this statement must move to its own file. +DO $$ +BEGIN + ALTER TYPE "VerificationStep" ADD VALUE IF NOT EXISTS 'REJECTED'; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + + +-- ---------------------------------------------------------------------------- +-- 5. CustomerFavorite — the legacy `fav` table finally gets a home +-- +-- Legacy `fav` holds 251 rows (customer_id, product_id) and the target had NO +-- model for it, so every customer's saved products would have been lost. +-- Owner ruling 2026-10-06: they matter. docs/PWA-PORT-PLAN.md reached the same +-- conclusion independently. +-- +-- Legacy `fav` has only two columns and NO primary key, so duplicate pairs are +-- possible in principle. The composite primary key here closes that, and the +-- precheck confirmed fav_duplicate_pairs = 0, so no de-duplication is needed — +-- but the tool should still SELECT DISTINCT, since the source cannot enforce it. +-- +-- 14 of the 251 rows point at a customer that no longer exists +-- (fav.customer_id orphans). Those must be SKIPPED by the tool; the foreign +-- keys below would reject them anyway. +-- +-- ON DELETE CASCADE on both sides: a favourite is a pure join row with no +-- meaning once either end is gone. This differs from ProductVariant.imageId, +-- which uses SetNull precisely so a deleted image never cascades into order +-- history — the distinction is deliberate. +-- +-- Legacy also keeps a denormalised `product.count_fav` counter. It is NOT +-- recreated as a column; derive it from this table (§5.2 group B) rather than +-- showing a stale or zero value. +CREATE TABLE IF NOT EXISTS "CustomerFavorite" ( + "customerId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "CustomerFavorite_pkey" PRIMARY KEY ("customerId", "productId") +); + +-- The PK already indexes (customerId, productId) left-to-right, so "my +-- favourites" needs no extra index. This one serves the reverse lookup — +-- "how many customers favourited this product" — which is how count_fav is +-- derived. +CREATE INDEX IF NOT EXISTS "CustomerFavorite_productId_idx" + ON "CustomerFavorite"("productId"); + +DO $$ +BEGIN + ALTER TABLE "CustomerFavorite" + ADD CONSTRAINT "CustomerFavorite_customerId_fkey" + FOREIGN KEY ("customerId") REFERENCES "Customer"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + ALTER TABLE "CustomerFavorite" + ADD CONSTRAINT "CustomerFavorite_productId_fkey" + FOREIGN KEY ("productId") REFERENCES "Product"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +COMMENT ON TABLE "CustomerFavorite" IS + 'Legacy `fav` (251 rows). Composite PK replaces the source table''s missing primary key. Derive product.count_fav from here rather than storing a counter.'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 18fadd2..91e7eb4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -56,6 +56,13 @@ enum VerificationStep { STEP_ONE_SUBMITTED STEP_TWO_SUBMITTED VERIFIED + + /// Legacy had THREE states per step, not two + /// (MajorCustomerController.php:106-135): 1 = awaiting, 2 = approved, + /// 3 = REJECTED. Mapping rejected customers to UNVERIFIED would put closed + /// cases back in the review queue, so the owner ruled it out (2026-10-07). + /// 14 legacy rows land here; see Customer.verificationRejectionReason. + REJECTED } /// Legacy kept retail and wholesale orders in two SEPARATE tables @@ -367,7 +374,18 @@ model Customer { lastName String? mobile String @unique - nationalCode String @unique + /// Legacy customers.ncode. OPTIONAL since 2026-10-07: not one of the 1,616 + /// legacy customers has a national code (1595/1595 empty), and fabricating + /// one is a data violation that would block later legal identity checks + /// (owner ruling). Still @unique — in PostgreSQL NULLs do not collide, so + /// the migration tool must write NULL and never '', which would collide on + /// the second row. + /// + /// Three service-layer workarounds exist ONLY because this used to be + /// required, and they store the MOBILE NUMBER here: + /// admin-customers.service.ts:625,685 and customer-auth.service.ts:258. + /// Retiring them is a separate change; see docs/DATA-MIGRATION-MAP.md §10.4. + nationalCode String? @unique passwordHash String cityId String? city City? @relation(fields: [cityId], references: [id]) @@ -429,6 +447,30 @@ model Customer { businessLicenseUrl String? shopImageUrl String? + /// Why verification was rejected (VerificationStep.REJECTED). + /// + /// Legacy recorded NO reason: none of its 49 `customers` columns is a + /// reason/note field, and on rejection it only sent a fixed SMS. So the 14 + /// rows migrated from henza carry the sentinel + /// «رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده» instead of NULL, so support + /// knows why it is blank (owner ruling 2026-10-07). The migration tool + /// writes it only WHERE this IS NULL, so a re-run cannot overwrite a real + /// reason entered in the new system. + verificationRejectionReason String? + + /// NULL for rows migrated from henza — legacy recorded no rejection time. + verificationRejectedAt DateTime? + + /// Legacy customers.birth_date, populated on 917 of 1,595 rows. Kept for the + /// birthday SMS / loyalty campaigns (owner ruling 2026-10-07). + /// + /// ⚠ Legacy stores dates as JALALI STRINGS in other tables + /// (cashes.recive_date is varchar, 88/89 values starting "14…"), so this + /// timestamp may hold mis-entered Jalali years. Confirm the calendar with + /// docs/migration-precheck-3.sql BEFORE backfilling: copying them unchanged + /// would produce 917 wrong birthdays, worse for an SMS campaign than none. + birthDate DateTime? + /// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent /// form. Legacy stored an FK to its Image table and read it back as a nested /// `image: { id, image }`, so this is a relation, not a bare URL string. @@ -457,12 +499,35 @@ model Customer { notifications NotificationRecipient[] activityLogs CustomerActivityLog[] otpChallenges CustomerOtp[] + favorites CustomerFavorite[] @@index([referredByAgentId]) @@index([firstName]) @@index([lastName]) } +/// Legacy `fav` (251 rows) — saved products. The target had no model for this, +/// so every customer's favourites would have been lost in the migration; the +/// owner confirmed on 2026-10-06 that they matter. +/// +/// Legacy `fav` has two columns and NO primary key. The composite key here +/// supplies the constraint the source lacks (the precheck found 0 duplicate +/// pairs, but the tool should still SELECT DISTINCT). 14 of the 251 rows point +/// at a deleted customer and must be skipped. +/// +/// Legacy's denormalised `product.count_fav` counter is deliberately NOT +/// recreated as a column — derive it from this table. +model CustomerFavorite { + customerId String + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@id([customerId, productId]) + @@index([productId]) +} + model CustomerAddress { id String @id @default(cuid()) customerId String @@ -579,6 +644,7 @@ model Product { comments Comment[] promotions Promotion[] allocationItems BranchAllocationItem[] + favoritedBy CustomerFavorite[] } model ProductImage { diff --git a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts index 1f308ce..cd5124c 100644 --- a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts +++ b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts @@ -230,7 +230,11 @@ export class AccountingSyncService { } private customerPayload( - c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string }, + // nationalCode is nullable since 2026-10-07 (no legacy customer has one — + // docs/DATA-MIGRATION-MAP.md §10.4). validNationalCode() already takes + // `string | null` and maps empty/invalid to null, so the providers below + // keep receiving exactly what they received before. + c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string | null }, group: ExternalGroup | null, ): CustomerPayload { const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);