fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose
Found by running the delivery flow against the actual built container rather
than a local dev process.
DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw
"expiresIn" should be a number of seconds or string representing a timespan
on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.
Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.
Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.
DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.
New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.
Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f549f1749
commit
24b7a4c395
+246
-16
@@ -28,12 +28,27 @@ enum AdminRole {
|
||||
|
||||
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
|
||||
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
|
||||
///
|
||||
/// SALES_AGENT is a LEGACY value only: legacy stored a sales agent as a
|
||||
/// Customer row with type=1, but the redesigned system models a sales agent
|
||||
/// as an AdminUser (role SALES_EXPERT) instead — see admin-users.service.ts
|
||||
/// and admin-customers.service.ts's listReagents()/create(). No Customer row
|
||||
/// should be created with kind SALES_AGENT going forward; the value is kept
|
||||
/// only so legacyTypeToKind()/kindToLegacyType() stay total functions and any
|
||||
/// already-migrated data keeps a readable label.
|
||||
///
|
||||
/// RETAIL marks a "تک" (single/retail) customer — legacy kept these in an
|
||||
/// entirely separate table (SingleCustomer/`login`) that could never contain
|
||||
/// a wholesale/agent/shopkeeper/banker row. The redesigned schema folded both
|
||||
/// into one Customer model, so this value is what listSingle() now filters on
|
||||
/// to reproduce that same separation.
|
||||
enum CustomerKind {
|
||||
SALES_AGENT // 1 کارشناس فروش
|
||||
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
|
||||
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
|
||||
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
|
||||
BANKER // 4 بنکدار
|
||||
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
|
||||
RETAIL // مشتری تک — legacy SingleCustomer
|
||||
}
|
||||
|
||||
enum VerificationStep {
|
||||
@@ -97,22 +112,39 @@ enum LedgerSourceType {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
||||
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
||||
percent Decimal? @db.Decimal(6, 3)
|
||||
percentAux Decimal? @db.Decimal(6, 3)
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
percent Decimal? @db.Decimal(6, 3)
|
||||
percentAux Decimal? @db.Decimal(6, 3)
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
salesExpertProfiles SalesExpertProfile[]
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// ⚠️ Kept in sync BY A DATABASE TRIGGER — `admin_user_sync_full_name`, from
|
||||
/// migration 20260922180000. Prisma cannot see it, so a write here may store
|
||||
/// something other than what you passed:
|
||||
///
|
||||
/// * write the parts -> fullName is rebuilt as "first last"
|
||||
/// * write fullName only -> the parts are re-derived from it
|
||||
/// * write BOTH in one update -> THE PARTS WIN; the submitted fullName is
|
||||
/// discarded
|
||||
///
|
||||
/// Re-read after writing if you need the stored value. Same arrangement as
|
||||
/// Customer (D0 requirement 7 — the split applies to ALL admins, not only
|
||||
/// sales experts).
|
||||
fullName String
|
||||
firstName String?
|
||||
lastName String?
|
||||
|
||||
mobile String @unique
|
||||
passwordHash String
|
||||
role AdminRole
|
||||
@@ -127,6 +159,18 @@ model AdminUser {
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// Legacy stored a sales agent as a `customers` row with type=1, so
|
||||
/// ReagentCustomer.vue's shared form collected these alongside the
|
||||
/// wholesale-customer fields. A sales agent is now an AdminUser
|
||||
/// (role=SALES_EXPERT), so these live here instead — only ever set for that
|
||||
/// role; every other AdminUser leaves them null.
|
||||
avatarFileId String?
|
||||
avatarFile UploadedFile? @relation("AdminUserAvatar", fields: [avatarFileId], references: [id])
|
||||
address String?
|
||||
storeName String? // legacy store_name
|
||||
pasazhName String? // legacy pasazh_name
|
||||
memberCode String? // legacy mcode
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||
@@ -137,13 +181,98 @@ model AdminUser {
|
||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||
|
||||
/// Only ever set for role = SALES_EXPERT (D17).
|
||||
salesExpertProfile SalesExpertProfile?
|
||||
|
||||
@@index([role])
|
||||
@@index([userTypeId])
|
||||
@@index([avatarFileId])
|
||||
@@index([firstName])
|
||||
@@index([lastName])
|
||||
}
|
||||
|
||||
/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
|
||||
/// were lost when a sales expert became an AdminUser (D17).
|
||||
///
|
||||
/// A separate table rather than columns on AdminUser: these are meaningful only
|
||||
/// for role = SALES_EXPERT and would be permanently null for every other admin.
|
||||
/// A row is created for every SALES_EXPERT by the migration, and upserted by the
|
||||
/// service whenever one is created or a user's role becomes SALES_EXPERT — but
|
||||
/// readers still left-join, so a missing profile degrades to nulls rather than
|
||||
/// dropping the agent from the grid.
|
||||
///
|
||||
/// `reagent_id` is deliberately NOT here — see D17: in the reference it was a
|
||||
/// side effect of the shared customers form, and filling it removed that expert
|
||||
/// from the selectable-agent list (queryReagent, AdminController.php:420).
|
||||
model SalesExpertProfile {
|
||||
id String @id @default(cuid())
|
||||
adminUserId String @unique
|
||||
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Legacy customers.city_id — a filterable grid column
|
||||
/// (ReagentListModel.js:14) and the source of the commission autofill
|
||||
/// (ReagentCustomer.vue:215-228).
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
/// Legacy customers.gram_level — the «سطح اعتبار» column, filterable.
|
||||
/// Decimal(14,4) matches Customer.goldCreditLimitGrams exactly so the same
|
||||
/// credit limit has identical precision on both tables.
|
||||
gramLevel Decimal @default(0) @db.Decimal(14, 4)
|
||||
|
||||
/// Legacy customers.phone (varchar 15) and ncode (varchar 20). The reference
|
||||
/// stores both and reads neither back; kept so the form matches it. Neither
|
||||
/// is unique and neither has a validation rule in the reference — inventing
|
||||
/// one would reject data the reference accepts.
|
||||
phone String? @db.VarChar(15)
|
||||
nationalCode String? @db.VarChar(20)
|
||||
|
||||
/// The Customer row carrying this expert's OWN orders, debt and credit limit.
|
||||
///
|
||||
/// The reference stored a sales agent AS a Customer (type=1), so an order the
|
||||
/// agent placed from the app was simply their own, against their own
|
||||
/// gram_level (ApiMobileController.php:534-535). Here the expert is an
|
||||
/// AdminUser (D17), which has no ledger and cannot be an Order's subject, so
|
||||
/// this one linked Customer plays that part.
|
||||
///
|
||||
/// Distinct from the expert's REFERRED customers, which are ordinary Customer
|
||||
/// rows pointing at the AdminUser through Customer.referredByAgentId.
|
||||
///
|
||||
/// Null until the expert first signs into the app, when it is created from
|
||||
/// this profile. See D21.
|
||||
tradingCustomerId String? @unique
|
||||
tradingCustomer Customer? @relation("SalesExpertTradingAccount", fields: [tradingCustomerId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@index([cityId])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
id String @id @default(cuid())
|
||||
|
||||
/// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE
|
||||
/// TRIGGER — `customer_sync_full_name`, created in migration
|
||||
/// 20260922120000_customer_name_parts. Prisma does not know about it, so a
|
||||
/// write here is NOT the whole story:
|
||||
///
|
||||
/// * write the parts -> fullName is rebuilt as "first last"
|
||||
/// * write fullName only -> the parts are re-derived from it
|
||||
/// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten
|
||||
/// with "first last" and whatever you passed for fullName is discarded
|
||||
///
|
||||
/// So a value you wrote can differ from the value that lands in the row.
|
||||
/// Re-read after writing if you need the stored value. Prefer writing the
|
||||
/// parts; `fullName` exists for the legacy readers that predate D0.
|
||||
///
|
||||
/// Reference has these as two columns from the start
|
||||
/// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname);
|
||||
/// the port collapsed them, which is the D0 defect.
|
||||
fullName String
|
||||
firstName String?
|
||||
lastName String?
|
||||
|
||||
mobile String @unique
|
||||
nationalCode String @unique
|
||||
passwordHash String
|
||||
@@ -167,12 +296,33 @@ model Customer {
|
||||
referredByAgentId String?
|
||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
||||
|
||||
/// Set only on the one Customer row that acts as a sales expert's own trading
|
||||
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
||||
/// ordinary customer.
|
||||
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
|
||||
/// ADDED to the variant's making charge when pricing an order line
|
||||
/// (henza_pwa src/Mixins/product/index.ts:69-75:
|
||||
/// getWage(v) = state.percent + v.percentage_pay)
|
||||
/// and it shifts the catalogue making-charge filter bounds
|
||||
/// (ApiMobileController.php:391-396,76-77). It is NOT part of the price
|
||||
/// formula itself — that extra percent is ConfigStatic['product_price_extra_percent'].
|
||||
///
|
||||
/// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and
|
||||
/// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot).
|
||||
///
|
||||
/// DB default 0 keeps every existing customer's price unchanged. The
|
||||
/// reference's application-layer default for NEW customers is 3
|
||||
/// (AdminController.php:338) and the field is required on create (:354).
|
||||
makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3)
|
||||
|
||||
verificationStep VerificationStep @default(UNVERIFIED)
|
||||
nationalIdImageUrl String?
|
||||
businessLicenseUrl String?
|
||||
@@ -205,8 +355,11 @@ model Customer {
|
||||
promotions Promotion[]
|
||||
notifications NotificationRecipient[]
|
||||
activityLogs CustomerActivityLog[]
|
||||
otpChallenges CustomerOtp[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
@@index([firstName])
|
||||
@@index([lastName])
|
||||
}
|
||||
|
||||
model CustomerAddress {
|
||||
@@ -329,6 +482,11 @@ model ProductImage {
|
||||
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
|
||||
/// existing rows and any generic "just attach an image" caller keep working.
|
||||
role ProductImageRole @default(GALLERY)
|
||||
|
||||
/// Variants that display this image. A gallery image may back several
|
||||
/// variants (one photo, several weights), which is why this is a plain
|
||||
/// one-to-many and not a join table.
|
||||
variants ProductVariant[]
|
||||
}
|
||||
|
||||
enum ProductImageRole {
|
||||
@@ -356,7 +514,16 @@ model ProductVariant {
|
||||
isPreOrder Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
/// The gallery image shown for this variant, picked from its own product's
|
||||
/// images — never a re-uploaded copy. onDelete: SetNull because dropping an
|
||||
/// image must never cascade into the variant: OrderItem rows reference
|
||||
/// variants, so losing one would break order history.
|
||||
imageId String?
|
||||
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
|
||||
|
||||
orderItems OrderItem[]
|
||||
|
||||
@@index([imageId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -861,8 +1028,71 @@ model UploadedFile {
|
||||
uploadedByAdminUserId String?
|
||||
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||
createdAt DateTime @default(now())
|
||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||
adminUserAvatars AdminUser[] @relation("AdminUserAvatar")
|
||||
|
||||
@@index([purpose])
|
||||
}
|
||||
|
||||
/// One OTP login challenge for a customer.
|
||||
///
|
||||
/// Ports henza_server's flow (ApiMobileController.php:39-78), where userAuth
|
||||
/// generated rand(1000,9999), stored it on the customer row as `send_password`
|
||||
/// and sent it by SMS, and userVerfiy compared the submitted code against it
|
||||
/// (mismatch -> HTTP 422).
|
||||
///
|
||||
/// Three deliberate improvements over the reference, approved by the user
|
||||
/// (docs/parity/DECISIONS.md D6 and T1):
|
||||
/// 1. the code is stored as a bcrypt hash, never in plain text — the
|
||||
/// reference kept it readable in a customer column;
|
||||
/// 2. it expires (`expiresAt`) — the reference's code stayed valid forever;
|
||||
/// 3. attempts are counted and the challenge locks out — the reference had
|
||||
/// no limit at all.
|
||||
/// The parity behaviour itself is the code comparison and the rejection.
|
||||
model CustomerOtp {
|
||||
id String @id @default(cuid())
|
||||
/// Normalised to English digits before storing, as the reference does with
|
||||
/// Utility::faTOen() on both send and verify.
|
||||
mobile String
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// bcrypt hash of the 4-digit code. Never the code itself.
|
||||
codeHash String
|
||||
expiresAt DateTime
|
||||
/// Failed verify attempts against THIS challenge.
|
||||
attempts Int @default(0)
|
||||
/// Set when the code is accepted, so one challenge cannot be reused.
|
||||
consumedAt DateTime?
|
||||
|
||||
/// Kept for the per-IP send throttle.
|
||||
requestIp String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([mobile, createdAt])
|
||||
@@index([customerId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Bespoke migration ledger left by the EARLIER, pre-Prisma port.
|
||||
///
|
||||
/// It predates Prisma Migrate in this repo and holds 12 historical rows
|
||||
/// (`0001_init.sql`, `0002_admin_schema.sql`, `seed:initial`, and the early
|
||||
/// `2026091…` migrations). It is the only record of how that port was applied,
|
||||
/// so it is **kept and never written to or deleted from** (DEAD-07).
|
||||
///
|
||||
/// It is declared here purely so `prisma migrate diff` stops proposing
|
||||
/// `DROP TABLE "_goldbank_migrations"` — an undeclared table reads as drift.
|
||||
/// `@@ignore` keeps it out of Prisma Client: nothing in the application should
|
||||
/// ever read or write it, and Prisma Migrate will not manage its shape either.
|
||||
///
|
||||
/// Prisma's own ledger is `_prisma_migrations`, which is separate and is never
|
||||
/// edited by hand (D14).
|
||||
model goldbank_migrations {
|
||||
name String @id
|
||||
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("_goldbank_migrations")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user