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
+32
-1
@@ -102,9 +102,17 @@ pnpm build:release # the production/Docker build
|
|||||||
### Deploy (Docker)
|
### Deploy (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
pnpm build:release # ⚠ REQUIRED FIRST — see below
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **⚠ `pnpm build:release` must be run before `docker compose build`.**
|
||||||
|
> The API image is `COPY release/ ./` — it ships a bundle built on the
|
||||||
|
> developer machine, not one built inside the image. So `docker compose build`
|
||||||
|
> will happily produce an image from a **stale** `release/app.js` and report
|
||||||
|
> success. During this delivery's compose run that bundle was four days old and
|
||||||
|
> the container was missing every route added since. Tracked as **DEPLOY-03**.
|
||||||
|
|
||||||
Services: `db` (Postgres 15), `api` (port 3000), `admin-panel`, `user-app`,
|
Services: `db` (Postgres 15), `api` (port 3000), `admin-panel`, `user-app`,
|
||||||
`user-app-v2` (each nginx on port 80). The apps expose no host ports —
|
`user-app-v2` (each nginx on port 80). The apps expose no host ports —
|
||||||
Dokploy/Traefik routes to them by domain. Uploads persist in the `api_uploads`
|
Dokploy/Traefik routes to them by domain. Uploads persist in the `api_uploads`
|
||||||
@@ -144,7 +152,7 @@ All green on `release/delivery`:
|
|||||||
| Suite | Result |
|
| Suite | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pnpm build` (5 workspaces) | ✅ all pass |
|
| `pnpm build` (5 workspaces) | ✅ all pass |
|
||||||
| jest (api unit) | **45/45** |
|
| jest (api unit) | **54/54** |
|
||||||
| `smoke-admin.cjs` — admin flows | **43/43** |
|
| `smoke-admin.cjs` — admin flows | **43/43** |
|
||||||
| `smoke-apps.cjs` — customer flows, both apps | **43/43** |
|
| `smoke-apps.cjs` — customer flows, both apps | **43/43** |
|
||||||
| `scan-admin-b2b.cjs` — every B2B read endpoint | **34 ok, 0 broken** |
|
| `scan-admin-b2b.cjs` — every B2B read endpoint | **34 ok, 0 broken** |
|
||||||
@@ -154,9 +162,32 @@ All green on `release/delivery`:
|
|||||||
| `test-sales-expert-app-login.cjs` — expert app login (D21) | **36/36** |
|
| `test-sales-expert-app-login.cjs` — expert app login (D21) | **36/36** |
|
||||||
| `test-complete-profile.cjs` — «تکمیل پروفایل» | **38/38** |
|
| `test-complete-profile.cjs` — «تکمیل پروفایل» | **38/38** |
|
||||||
| `test-report.cjs` — «گزارش» | **40/40** |
|
| `test-report.cjs` — «گزارش» | **40/40** |
|
||||||
|
| `test-compose-e2e.cjs` — **the whole flow inside Docker Compose** | **32/32** |
|
||||||
| `test-name-trigger.cjs` | **9/9** |
|
| `test-name-trigger.cjs` | **9/9** |
|
||||||
| typecheck (api, admin-panel, user-app, user-app-v2) | ✅ clean |
|
| typecheck (api, admin-panel, user-app, user-app-v2) | ✅ clean |
|
||||||
|
|
||||||
|
### Verified inside Docker Compose, not just locally
|
||||||
|
|
||||||
|
The whole delivery flow was run against the **built container**, which is what
|
||||||
|
actually ships: `test-compose-e2e.cjs` signs a **sales expert** and an
|
||||||
|
**ordinary customer** into the app with the master code, has **both place an
|
||||||
|
order**, and confirms **both orders appear in the admin panel** and open there.
|
||||||
|
It also re-checks the three token boundaries in the deployed image. **32/32.**
|
||||||
|
The three page suites were re-run against the container too — complete-profile
|
||||||
|
38/38, report 40/40, expert login 36/36.
|
||||||
|
|
||||||
|
That run found two deployment problems that no local test could have caught:
|
||||||
|
|
||||||
|
- **DEPLOY-02 (fixed):** admin login returned **500 for every user in Docker**.
|
||||||
|
Compose passes optional variables as `${X:-}`, so an unconfigured one arrives
|
||||||
|
as `""`, not `undefined`; the code used `?? 'default'`, which only falls back
|
||||||
|
on `undefined`, so the empty string reached `jsonwebtoken` and it threw on
|
||||||
|
every sign. Fixed with `src/common/env.ts`, applied to every auth, port,
|
||||||
|
upload and SMS read, and pinned by a test that reproduces the original throw.
|
||||||
|
- **DEPLOY-03 (process):** the API image ships a **pre-built** bundle, so
|
||||||
|
`docker compose build` can silently produce an image from stale code. See the
|
||||||
|
warning in the deploy section above.
|
||||||
|
|
||||||
Flows walked end to end: admin login, customer creation, sales-expert
|
Flows walked end to end: admin login, customer creation, sales-expert
|
||||||
create/edit, product and variant create/edit, order list/detail/status change,
|
create/edit, product and variant create/edit, order list/detail/status change,
|
||||||
wholesale order placement from the admin panel, cash document creation +
|
wholesale order placement from the admin panel, cash document creation +
|
||||||
|
|||||||
+4534
-1694
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ProductVariant" ADD COLUMN "imageId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ProductVariant_imageId_idx" ON "ProductVariant"("imageId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
-- ON DELETE SET NULL: removing a gallery image must never cascade into the
|
||||||
|
-- variant, because OrderItem rows reference variants and losing one would
|
||||||
|
-- break order history. The variant simply loses its picture.
|
||||||
|
ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Sales agents ("کارشناس فروش") are modelled as AdminUser rows
|
||||||
|
-- (role=SALES_EXPERT), not Customer rows — see admin-users.service.ts and
|
||||||
|
-- admin-customers.service.ts's listReagents(). ReagentCustomer.vue used to
|
||||||
|
-- post agent data at the Customer endpoints instead, so agents ended up as
|
||||||
|
-- Customer rows (kind SALES_AGENT), which then leaked into every
|
||||||
|
-- Customer-backed list, including "لیست مشتریان تک" (listSingle had no kind
|
||||||
|
-- filter at all). Fixed at the application layer alongside this migration;
|
||||||
|
-- this migration only adds what that fix needs:
|
||||||
|
--
|
||||||
|
-- 1. CustomerKind.RETAIL — legacy kept retail ("تک") customers in an
|
||||||
|
-- entirely separate table (SingleCustomer/`login`) that could never
|
||||||
|
-- contain a wholesale/agent/shopkeeper/banker row. The redesigned
|
||||||
|
-- schema folded everything into one Customer model, so listSingle()
|
||||||
|
-- needs an explicit kind to filter on to reproduce that separation.
|
||||||
|
-- 2. AdminUser gets the handful of Customer-only columns
|
||||||
|
-- (ReagentCustomer.vue) that a sales agent's form actually collects:
|
||||||
|
-- avatar, address, store/pasazh name, member code. Only ever set when
|
||||||
|
-- role=SALES_EXPERT; every other AdminUser leaves them null.
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "CustomerKind" ADD VALUE IF NOT EXISTS 'RETAIL';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "AdminUser"
|
||||||
|
ADD COLUMN "avatarFileId" TEXT,
|
||||||
|
ADD COLUMN "address" TEXT,
|
||||||
|
ADD COLUMN "storeName" TEXT,
|
||||||
|
ADD COLUMN "pasazhName" TEXT,
|
||||||
|
ADD COLUMN "memberCode" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AdminUser_avatarFileId_idx" ON "AdminUser"("avatarFileId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_avatarFileId_fkey"
|
||||||
|
FOREIGN KEY ("avatarFileId") REFERENCES "UploadedFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Customer OTP login challenges.
|
||||||
|
--
|
||||||
|
-- Additive only: creates one new table and its indexes. No existing table,
|
||||||
|
-- column or row is touched.
|
||||||
|
--
|
||||||
|
-- Ports henza_server's SMS login (ApiMobileController.php:39-78). The reference
|
||||||
|
-- kept the code in plain text on the customer row with no expiry and no attempt
|
||||||
|
-- limit; this stores a bcrypt hash and adds both, per docs/parity/DECISIONS.md
|
||||||
|
-- D6 / T1.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "CustomerOtp" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"mobile" TEXT NOT NULL,
|
||||||
|
"customerId" TEXT NOT NULL,
|
||||||
|
"codeHash" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"consumedAt" TIMESTAMP(3),
|
||||||
|
"requestIp" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "CustomerOtp_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CustomerOtp_mobile_createdAt_idx"
|
||||||
|
ON "CustomerOtp"("mobile", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "CustomerOtp_customerId_idx"
|
||||||
|
ON "CustomerOtp"("customerId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "CustomerOtp_expiresAt_idx"
|
||||||
|
ON "CustomerOtp"("expiresAt");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "CustomerOtp"
|
||||||
|
ADD CONSTRAINT "CustomerOtp_customerId_fkey"
|
||||||
|
FOREIGN KEY ("customerId") REFERENCES "Customer"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE).
|
||||||
|
--
|
||||||
|
-- Reference: henza_server has two separate columns from the start
|
||||||
|
-- database/migrations/2020_10_01_120430_customers_table.php:18-19
|
||||||
|
-- $table->string('fname')->nullable();
|
||||||
|
-- $table->string('lname')->nullable();
|
||||||
|
-- The target collapsed them into one `fullName`, so the admin edit form shows
|
||||||
|
-- the whole name in the "first name" box and saves an empty last name
|
||||||
|
-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`).
|
||||||
|
--
|
||||||
|
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as
|
||||||
|
-- `firstName || ' ' || lastName`, so every existing reader keeps working
|
||||||
|
-- (D0 requirement 4).
|
||||||
|
--
|
||||||
|
-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and
|
||||||
|
-- the retail storefront read it. This migration only ADDS two nullable columns
|
||||||
|
-- and leaves `fullName` populated and authoritative, so no storefront query,
|
||||||
|
-- view or report changes behaviour. No storefront column is touched.
|
||||||
|
|
||||||
|
-- 1. The two new columns. Nullable: a customer may legitimately have no
|
||||||
|
-- surname, and the reference's own columns are nullable.
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
|
||||||
|
|
||||||
|
-- 2. Backfill: first whitespace-separated token -> firstName, the REST ->
|
||||||
|
-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace
|
||||||
|
-- are normalised first so " علی محمدی " splits correctly.
|
||||||
|
--
|
||||||
|
-- Rows with a single token get firstName = that token and lastName = NULL.
|
||||||
|
-- Rows with more than two tokens put every remaining token in lastName;
|
||||||
|
-- they are ALSO listed in the review report (see the companion script) so a
|
||||||
|
-- human can correct them — the split cannot be guessed safely
|
||||||
|
-- (D0 requirement 3).
|
||||||
|
UPDATE "Customer"
|
||||||
|
SET
|
||||||
|
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
|
||||||
|
"lastName" = NULLIF(
|
||||||
|
substr(
|
||||||
|
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
|
||||||
|
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||||
|
),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
WHERE "firstName" IS NULL
|
||||||
|
AND "fullName" IS NOT NULL
|
||||||
|
AND btrim("fullName") <> '';
|
||||||
|
|
||||||
|
-- 3. Keep `fullName` in sync from now on.
|
||||||
|
--
|
||||||
|
-- Writers may update either the parts or the whole; this trigger makes the
|
||||||
|
-- parts authoritative whenever they are the thing that changed, and
|
||||||
|
-- otherwise leaves an explicit `fullName` write alone. That way old code
|
||||||
|
-- that still writes only `fullName` is not broken (D0 requirement 4).
|
||||||
|
-- Precedence when BOTH the parts and fullName change in the SAME statement:
|
||||||
|
-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is
|
||||||
|
-- discarded. Rationale: after D0 the parts are the field the forms edit, so
|
||||||
|
-- they carry the operator's intent; fullName is the derived, legacy-facing
|
||||||
|
-- value. Choosing the opposite would silently undo an edit made in the two
|
||||||
|
-- boxes the operator actually typed into.
|
||||||
|
-- The first branch below tests the parts, so it wins by construction.
|
||||||
|
CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
-- The name parts changed (or are being set on insert): rebuild fullName.
|
||||||
|
-- This is checked FIRST, so when both change in one statement the parts win.
|
||||||
|
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
|
||||||
|
OR (TG_OP = 'UPDATE'
|
||||||
|
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
|
||||||
|
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
|
||||||
|
NEW."fullName" := btrim(
|
||||||
|
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only fullName changed: derive the parts from it, so the two never drift.
|
||||||
|
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
|
||||||
|
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
|
||||||
|
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
|
||||||
|
NEW."lastName" := NULLIF(
|
||||||
|
substr(
|
||||||
|
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
|
||||||
|
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||||
|
),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer";
|
||||||
|
CREATE TRIGGER customer_sync_full_name_trg
|
||||||
|
BEFORE INSERT OR UPDATE ON "Customer"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name();
|
||||||
|
|
||||||
|
-- 4. Name search hits both parts in the admin grid.
|
||||||
|
CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName");
|
||||||
|
CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName");
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral).
|
||||||
|
--
|
||||||
|
-- Reference: `customers.percent`, a real per-customer column.
|
||||||
|
-- database/migrations/2020_10_19_153228_customer_add.php:32
|
||||||
|
-- $table->double('percent')->default(0);
|
||||||
|
--
|
||||||
|
-- What it does in the reference:
|
||||||
|
-- * it is ADDED to the variant's making charge when the app builds an order
|
||||||
|
-- line: henza_pwa/src/Mixins/product/index.ts:69-75
|
||||||
|
-- getWage(v) = myStore.state.percent + v.percentage_pay
|
||||||
|
-- and `state.percent` is this column, delivered by POST /me-data
|
||||||
|
-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127).
|
||||||
|
-- * it shifts the catalogue making-charge filter bounds:
|
||||||
|
-- ApiMobileController.php:391-396 and :76-77.
|
||||||
|
-- * it is NOT part of the price formula itself — the extra percent there
|
||||||
|
-- comes from ConfigStatic['product_price_extra_percent']
|
||||||
|
-- (ProductVariant.php:50,183).
|
||||||
|
--
|
||||||
|
-- Why this name and not `productPercent`: the reference has three unrelated
|
||||||
|
-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the
|
||||||
|
-- per-customer making-charge offset. It is mapped back to the legacy key
|
||||||
|
-- `percent` in toRow(), so the admin panel's field name does not change.
|
||||||
|
-- No duplicate field is created.
|
||||||
|
--
|
||||||
|
-- The target currently reads `referredByAgent.wagePercent` instead, which is
|
||||||
|
-- the wrong field entirely (CUST-02) — that is corrected in the service layer,
|
||||||
|
-- not here.
|
||||||
|
--
|
||||||
|
-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field;
|
||||||
|
-- it is consumed only by the B2B app's cart and catalogue filter. The
|
||||||
|
-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback
|
||||||
|
-- rate), a different key in a different table, which this migration does not
|
||||||
|
-- touch. `Customer` is a shared table, but adding a column with a default that
|
||||||
|
-- reproduces today's behaviour changes no storefront read.
|
||||||
|
|
||||||
|
-- DEFAULT 0 is deliberate and behaviour-neutral:
|
||||||
|
-- * it matches the reference's own column default (customer_add.php:32);
|
||||||
|
-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly
|
||||||
|
-- the making charge alone — which is what the target does today, since it
|
||||||
|
-- has no such column. So no existing customer's price moves.
|
||||||
|
-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers
|
||||||
|
-- (AdminController.php:338) and makes the field required on create (:354).
|
||||||
|
-- That is an application-layer default for new rows, NOT a backfill value,
|
||||||
|
-- and it is handled in the service, not in this migration.
|
||||||
|
ALTER TABLE "Customer"
|
||||||
|
ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS
|
||||||
|
'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).';
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
-- D17 — SalesExpertProfile, and firstName/lastName on AdminUser.
|
||||||
|
--
|
||||||
|
-- Two additive changes in one migration:
|
||||||
|
--
|
||||||
|
-- 1. SalesExpertProfile — the sales-expert fields that legacy kept on the
|
||||||
|
-- `customers` row (type=1) and that were lost when a sales expert became
|
||||||
|
-- an AdminUser. A separate table, not columns on AdminUser, because these
|
||||||
|
-- are meaningful only for role = SALES_EXPERT and would be permanently
|
||||||
|
-- null for every other admin.
|
||||||
|
--
|
||||||
|
-- 2. AdminUser.firstName / lastName — closes D0 requirement 7, applying the
|
||||||
|
-- same split the Customer table already got. For ALL admins, not only
|
||||||
|
-- sales experts.
|
||||||
|
--
|
||||||
|
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync by a trigger.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENT (D14/D15): this must run cleanly under BOTH paths — the Prisma CLI
|
||||||
|
-- in development, and the app's own runner (src/prisma/migrate.ts) at container
|
||||||
|
-- boot, which may replay it. Hence IF NOT EXISTS everywhere, CREATE OR REPLACE
|
||||||
|
-- for the function, DROP ... IF EXISTS before CREATE TRIGGER, and a guarded
|
||||||
|
-- backfill that only touches rows not already filled.
|
||||||
|
--
|
||||||
|
-- D13 / storefront impact: NONE. AdminUser is an admin-side table the retail
|
||||||
|
-- storefront never reads, and SalesExpertProfile is new. `customers` is not
|
||||||
|
-- touched.
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 1. SalesExpertProfile
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "SalesExpertProfile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"adminUserId" TEXT NOT NULL,
|
||||||
|
-- legacy customers.city_id — a filterable column on the sales-expert grid
|
||||||
|
-- (ReagentListModel.js:14) AND the source of the commission autofill
|
||||||
|
-- (ReagentCustomer.vue:215-228).
|
||||||
|
"cityId" TEXT,
|
||||||
|
-- legacy customers.gram_level — the «سطح اعتبار» column, filterable
|
||||||
|
-- (ReagentListModel.js:17).
|
||||||
|
--
|
||||||
|
-- DECIMAL(14,4) deliberately matches Customer.goldCreditLimitGrams exactly
|
||||||
|
-- (schema.prisma:221, verified numeric_precision=14 numeric_scale=4), so the
|
||||||
|
-- same credit limit has identical precision and rendering on both tables.
|
||||||
|
"gramLevel" DECIMAL(14,4) NOT NULL DEFAULT 0,
|
||||||
|
-- legacy customers.phone / ncode — collected by the reference form
|
||||||
|
-- (ReagentCustomer.vue:95,31) and stored, though nothing reads them back.
|
||||||
|
-- Added on the owner's instruction so the form matches the reference.
|
||||||
|
--
|
||||||
|
-- Types and constraints copied from the reference, which is deliberately lax:
|
||||||
|
-- ncode : string(20), NULLABLE, **NOT UNIQUE**
|
||||||
|
-- (2020_10_01_120430_customers_table.php:22)
|
||||||
|
-- phone : string(15), NULLABLE, not unique
|
||||||
|
-- (2020_10_25_155247_customers_cols.php:17)
|
||||||
|
-- Neither has a server-side validation rule: createCustomer validates only
|
||||||
|
-- `mobile` (required|unique) and `percent` (required) — AdminController.php:354.
|
||||||
|
-- Client-side both are `required: false` (ReagentCustomer.vue:31,95).
|
||||||
|
-- No uniqueness is invented here; that would reject data the reference accepts.
|
||||||
|
"phone" VARCHAR(15),
|
||||||
|
"nationalCode" VARCHAR(20),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "SalesExpertProfile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One profile per admin user.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_adminUserId_key"
|
||||||
|
ON "SalesExpertProfile" ("adminUserId");
|
||||||
|
|
||||||
|
-- The grid filters on city.
|
||||||
|
CREATE INDEX IF NOT EXISTS "SalesExpertProfile_cityId_idx"
|
||||||
|
ON "SalesExpertProfile" ("cityId");
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "SalesExpertProfile"
|
||||||
|
ADD CONSTRAINT "SalesExpertProfile_adminUserId_fkey"
|
||||||
|
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "SalesExpertProfile"
|
||||||
|
ADD CONSTRAINT "SalesExpertProfile_cityId_fkey"
|
||||||
|
FOREIGN KEY ("cityId") REFERENCES "City"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Give every EXISTING sales expert a profile row, so the grid's city and
|
||||||
|
-- credit-limit columns have something to left-join to from day one.
|
||||||
|
--
|
||||||
|
-- Idempotent by construction: `WHERE NOT EXISTS` means a replay inserts
|
||||||
|
-- nothing, and it never touches a profile an operator has already filled in.
|
||||||
|
--
|
||||||
|
-- The id is derived from the AdminUser id rather than random, so a replay on a
|
||||||
|
-- database where the row was deleted recreates the same key instead of
|
||||||
|
-- accumulating duplicates. Values are the column defaults: gramLevel 0 and
|
||||||
|
-- nulls elsewhere — deliberately neutral, since the reference has no data to
|
||||||
|
-- migrate here and inventing a credit limit would change behaviour.
|
||||||
|
INSERT INTO "SalesExpertProfile" ("id", "adminUserId", "gramLevel", "createdAt", "updatedAt")
|
||||||
|
SELECT 'sep_' || a."id", a."id", 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
FROM "AdminUser" a
|
||||||
|
WHERE a."role" = 'SALES_EXPERT'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "SalesExpertProfile" s WHERE s."adminUserId" = a."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
-- 2. AdminUser.firstName / lastName (D0 requirement 7)
|
||||||
|
-- ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
|
||||||
|
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
|
||||||
|
|
||||||
|
-- Backfill: first whitespace-separated token -> firstName, the REST ->
|
||||||
|
-- lastName. Identical rule to the Customer migration, so both tables split the
|
||||||
|
-- same way. Rows with more than two tokens are a guess and are listed by
|
||||||
|
-- scripts/report-name-split.cjs for a human to correct.
|
||||||
|
--
|
||||||
|
-- `WHERE "firstName" IS NULL` makes the replay a no-op and, just as important,
|
||||||
|
-- prevents a re-run from overwriting a name a human has since corrected.
|
||||||
|
UPDATE "AdminUser"
|
||||||
|
SET
|
||||||
|
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
|
||||||
|
"lastName" = NULLIF(
|
||||||
|
substr(
|
||||||
|
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
|
||||||
|
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||||
|
),
|
||||||
|
''
|
||||||
|
)
|
||||||
|
WHERE "firstName" IS NULL
|
||||||
|
AND "fullName" IS NOT NULL
|
||||||
|
AND btrim("fullName") <> '';
|
||||||
|
|
||||||
|
-- Same two-way sync as Customer.
|
||||||
|
--
|
||||||
|
-- Precedence when BOTH the parts and fullName change in ONE statement:
|
||||||
|
-- THE PARTS WIN — fullName is rebuilt from them and the submitted fullName is
|
||||||
|
-- discarded. The parts are what the forms edit, so they carry the operator's
|
||||||
|
-- intent; fullName is the derived, legacy-facing value. The first branch tests
|
||||||
|
-- the parts, so it wins by construction.
|
||||||
|
CREATE OR REPLACE FUNCTION admin_user_sync_full_name() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
|
||||||
|
OR (TG_OP = 'UPDATE'
|
||||||
|
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
|
||||||
|
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
|
||||||
|
NEW."fullName" := btrim(
|
||||||
|
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
|
||||||
|
);
|
||||||
|
|
||||||
|
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
|
||||||
|
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
|
||||||
|
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
|
||||||
|
NEW."lastName" := NULLIF(
|
||||||
|
substr(
|
||||||
|
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
|
||||||
|
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
|
||||||
|
),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS admin_user_sync_full_name_trg ON "AdminUser";
|
||||||
|
CREATE TRIGGER admin_user_sync_full_name_trg
|
||||||
|
BEFORE INSERT OR UPDATE ON "AdminUser"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
|
||||||
|
|
||||||
|
-- Name search and sort hit both parts.
|
||||||
|
CREATE INDEX IF NOT EXISTS "AdminUser_firstName_idx" ON "AdminUser" ("firstName");
|
||||||
|
CREATE INDEX IF NOT EXISTS "AdminUser_lastName_idx" ON "AdminUser" ("lastName");
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
-- Sales-expert app login: the expert's own trading account.
|
||||||
|
--
|
||||||
|
-- WHY THIS COLUMN EXISTS
|
||||||
|
-- ----------------------
|
||||||
|
-- In the reference a sales agent WAS a Customer row (customers.type = 1), so
|
||||||
|
-- when the agent placed an order from the app, setOrder did
|
||||||
|
-- $customer = Customer::findOrFail(auth()->id());
|
||||||
|
-- $gramLevel = $customer->gram_level;
|
||||||
|
-- (ApiMobileController.php:534-535) — the order was simply that agent's own
|
||||||
|
-- order, against that agent's own credit limit and ledger.
|
||||||
|
--
|
||||||
|
-- This system models a sales expert as an AdminUser (role SALES_EXPERT, D17),
|
||||||
|
-- which has no ledger, no gold debt and no credit limit. An AdminUser therefore
|
||||||
|
-- cannot be the subject of an Order: Order.customerId is a required FK to
|
||||||
|
-- Customer, and every ledger entry hangs off a Customer.
|
||||||
|
--
|
||||||
|
-- Rather than reverse D17 (the owner ruled the expert stays an AdminUser), each
|
||||||
|
-- expert gets ONE linked Customer row that represents only their own trading
|
||||||
|
-- position. Orders the expert places for themselves land on that row, exactly
|
||||||
|
-- as the reference's single row did. The expert's REFERRED customers remain
|
||||||
|
-- separate rows pointing at the AdminUser via Customer.referredByAgentId —
|
||||||
|
-- that relationship is unchanged.
|
||||||
|
--
|
||||||
|
-- PURELY ADDITIVE
|
||||||
|
-- ---------------
|
||||||
|
-- One nullable column plus a unique index. No existing row is modified, no
|
||||||
|
-- data is backfilled and nothing is deleted. A profile with a NULL
|
||||||
|
-- tradingCustomerId simply has no trading account yet; the API creates it
|
||||||
|
-- lazily on first login, so existing experts keep working untouched.
|
||||||
|
|
||||||
|
ALTER TABLE "SalesExpertProfile"
|
||||||
|
ADD COLUMN IF NOT EXISTS "tradingCustomerId" TEXT;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "SalesExpertProfile"."tradingCustomerId" IS
|
||||||
|
'The Customer row that carries this expert''s OWN orders, debt and credit limit. Their referred customers are separate rows linked by Customer.referredByAgentId. Null until the expert first signs into the app, when it is created from the profile (gramLevel -> goldCreditLimitGrams, wagePercent -> makingChargeOffsetPercent).';
|
||||||
|
|
||||||
|
-- One trading account per expert, and no two experts may share one.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_tradingCustomerId_key"
|
||||||
|
ON "SalesExpertProfile" ("tradingCustomerId");
|
||||||
|
|
||||||
|
-- SET NULL, not CASCADE: if the trading Customer is ever removed, the expert
|
||||||
|
-- and their profile must survive — deleting a customer must never delete an
|
||||||
|
-- admin account.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE "SalesExpertProfile"
|
||||||
|
ADD CONSTRAINT "SalesExpertProfile_tradingCustomerId_fkey"
|
||||||
|
FOREIGN KEY ("tradingCustomerId") REFERENCES "Customer"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
+49
-10
File diff suppressed because one or more lines are too long
+42
-4
@@ -133,6 +133,8 @@ exports.Prisma.CityScalarFieldEnum = {
|
|||||||
exports.Prisma.AdminUserScalarFieldEnum = {
|
exports.Prisma.AdminUserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
passwordHash: 'passwordHash',
|
passwordHash: 'passwordHash',
|
||||||
role: 'role',
|
role: 'role',
|
||||||
@@ -140,12 +142,31 @@ exports.Prisma.AdminUserScalarFieldEnum = {
|
|||||||
branchId: 'branchId',
|
branchId: 'branchId',
|
||||||
wagePercent: 'wagePercent',
|
wagePercent: 'wagePercent',
|
||||||
isActive: 'isActive',
|
isActive: 'isActive',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt',
|
||||||
|
avatarFileId: 'avatarFileId',
|
||||||
|
address: 'address',
|
||||||
|
storeName: 'storeName',
|
||||||
|
pasazhName: 'pasazhName',
|
||||||
|
memberCode: 'memberCode'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SalesExpertProfileScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
adminUserId: 'adminUserId',
|
||||||
|
cityId: 'cityId',
|
||||||
|
gramLevel: 'gramLevel',
|
||||||
|
phone: 'phone',
|
||||||
|
nationalCode: 'nationalCode',
|
||||||
|
tradingCustomerId: 'tradingCustomerId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.CustomerScalarFieldEnum = {
|
exports.Prisma.CustomerScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
nationalCode: 'nationalCode',
|
nationalCode: 'nationalCode',
|
||||||
passwordHash: 'passwordHash',
|
passwordHash: 'passwordHash',
|
||||||
@@ -161,6 +182,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
|||||||
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
||||||
goldDebtGrams: 'goldDebtGrams',
|
goldDebtGrams: 'goldDebtGrams',
|
||||||
fiatDebtRial: 'fiatDebtRial',
|
fiatDebtRial: 'fiatDebtRial',
|
||||||
|
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
|
||||||
verificationStep: 'verificationStep',
|
verificationStep: 'verificationStep',
|
||||||
nationalIdImageUrl: 'nationalIdImageUrl',
|
nationalIdImageUrl: 'nationalIdImageUrl',
|
||||||
businessLicenseUrl: 'businessLicenseUrl',
|
businessLicenseUrl: 'businessLicenseUrl',
|
||||||
@@ -256,7 +278,8 @@ exports.Prisma.ProductVariantScalarFieldEnum = {
|
|||||||
defaultWagePercent: 'defaultWagePercent',
|
defaultWagePercent: 'defaultWagePercent',
|
||||||
qty: 'qty',
|
qty: 'qty',
|
||||||
isPreOrder: 'isPreOrder',
|
isPreOrder: 'isPreOrder',
|
||||||
isActive: 'isActive'
|
isActive: 'isActive',
|
||||||
|
imageId: 'imageId'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.OrderScalarFieldEnum = {
|
exports.Prisma.OrderScalarFieldEnum = {
|
||||||
@@ -491,6 +514,18 @@ exports.Prisma.UploadedFileScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.CustomerOtpScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
mobile: 'mobile',
|
||||||
|
customerId: 'customerId',
|
||||||
|
codeHash: 'codeHash',
|
||||||
|
expiresAt: 'expiresAt',
|
||||||
|
attempts: 'attempts',
|
||||||
|
consumedAt: 'consumedAt',
|
||||||
|
requestIp: 'requestIp',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -528,7 +563,8 @@ exports.CustomerKind = exports.$Enums.CustomerKind = {
|
|||||||
APP_SHOPKEEPER: 'APP_SHOPKEEPER',
|
APP_SHOPKEEPER: 'APP_SHOPKEEPER',
|
||||||
WALK_IN_SHOPKEEPER: 'WALK_IN_SHOPKEEPER',
|
WALK_IN_SHOPKEEPER: 'WALK_IN_SHOPKEEPER',
|
||||||
BANKER: 'BANKER',
|
BANKER: 'BANKER',
|
||||||
WORKSHOP_SUPERVISOR: 'WORKSHOP_SUPERVISOR'
|
WORKSHOP_SUPERVISOR: 'WORKSHOP_SUPERVISOR',
|
||||||
|
RETAIL: 'RETAIL'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.VerificationStep = exports.$Enums.VerificationStep = {
|
exports.VerificationStep = exports.$Enums.VerificationStep = {
|
||||||
@@ -621,6 +657,7 @@ exports.MessageAudience = exports.$Enums.MessageAudience = {
|
|||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
City: 'City',
|
City: 'City',
|
||||||
AdminUser: 'AdminUser',
|
AdminUser: 'AdminUser',
|
||||||
|
SalesExpertProfile: 'SalesExpertProfile',
|
||||||
Customer: 'Customer',
|
Customer: 'Customer',
|
||||||
CustomerAddress: 'CustomerAddress',
|
CustomerAddress: 'CustomerAddress',
|
||||||
Branch: 'Branch',
|
Branch: 'Branch',
|
||||||
@@ -653,7 +690,8 @@ exports.Prisma.ModelName = {
|
|||||||
NotificationRecipient: 'NotificationRecipient',
|
NotificationRecipient: 'NotificationRecipient',
|
||||||
NewsletterSubscriber: 'NewsletterSubscriber',
|
NewsletterSubscriber: 'NewsletterSubscriber',
|
||||||
CustomerActivityLog: 'CustomerActivityLog',
|
CustomerActivityLog: 'CustomerActivityLog',
|
||||||
UploadedFile: 'UploadedFile'
|
UploadedFile: 'UploadedFile',
|
||||||
|
CustomerOtp: 'CustomerOtp'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5863
-74
File diff suppressed because it is too large
Load Diff
+49
-10
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-ed6338840581098d1a6f6e7ebfa38995b890cf67ba58cec8ad302fd997d92a01",
|
"name": "prisma-client-4b01ea5ca305ee68796b97b40684b681824ae0788fb6c2d4d029c05f8b265ccc",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+246
-16
@@ -28,12 +28,27 @@ enum AdminRole {
|
|||||||
|
|
||||||
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
|
/// Legacy Customer.type — what kind of wholesale customer this is. Values and
|
||||||
/// labels taken verbatim from bulkCustomer.vue's userTypes list.
|
/// 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 {
|
enum CustomerKind {
|
||||||
SALES_AGENT // 1 کارشناس فروش
|
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
|
||||||
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
|
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
|
||||||
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
|
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
|
||||||
BANKER // 4 بنکدار
|
BANKER // 4 بنکدار
|
||||||
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
|
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
|
||||||
|
RETAIL // مشتری تک — legacy SingleCustomer
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VerificationStep {
|
enum VerificationStep {
|
||||||
@@ -97,22 +112,39 @@ enum LedgerSourceType {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
model City {
|
model City {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
province String?
|
province String?
|
||||||
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
/// Legacy cities.percent / percent_aux — per-city wage surcharges the city
|
||||||
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
/// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}.
|
||||||
percent Decimal? @db.Decimal(6, 3)
|
percent Decimal? @db.Decimal(6, 3)
|
||||||
percentAux Decimal? @db.Decimal(6, 3)
|
percentAux Decimal? @db.Decimal(6, 3)
|
||||||
customers Customer[]
|
customers Customer[]
|
||||||
addresses CustomerAddress[]
|
addresses CustomerAddress[]
|
||||||
branches Branch[]
|
branches Branch[]
|
||||||
preOrders PreOrder[]
|
preOrders PreOrder[]
|
||||||
|
salesExpertProfiles SalesExpertProfile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model AdminUser {
|
model AdminUser {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
fullName String
|
|
||||||
|
/// ⚠️ 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
|
mobile String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role AdminRole
|
role AdminRole
|
||||||
@@ -127,6 +159,18 @@ model AdminUser {
|
|||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
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")
|
referredCustomers Customer[] @relation("ReferralAgent")
|
||||||
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
supervisedWorkshops Workshop[] @relation("WorkshopSupervisor")
|
||||||
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
branchAllocations BranchAllocation[] @relation("AllocationAuthor")
|
||||||
@@ -137,13 +181,98 @@ model AdminUser {
|
|||||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||||
|
|
||||||
|
/// Only ever set for role = SALES_EXPERT (D17).
|
||||||
|
salesExpertProfile SalesExpertProfile?
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([userTypeId])
|
@@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 {
|
model Customer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
fullName String
|
|
||||||
|
/// ⚠️ `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
|
mobile String @unique
|
||||||
nationalCode String @unique
|
nationalCode String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
@@ -167,12 +296,33 @@ model Customer {
|
|||||||
referredByAgentId String?
|
referredByAgentId String?
|
||||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
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
|
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||||
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
||||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_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)
|
verificationStep VerificationStep @default(UNVERIFIED)
|
||||||
nationalIdImageUrl String?
|
nationalIdImageUrl String?
|
||||||
businessLicenseUrl String?
|
businessLicenseUrl String?
|
||||||
@@ -205,8 +355,11 @@ model Customer {
|
|||||||
promotions Promotion[]
|
promotions Promotion[]
|
||||||
notifications NotificationRecipient[]
|
notifications NotificationRecipient[]
|
||||||
activityLogs CustomerActivityLog[]
|
activityLogs CustomerActivityLog[]
|
||||||
|
otpChallenges CustomerOtp[]
|
||||||
|
|
||||||
@@index([referredByAgentId])
|
@@index([referredByAgentId])
|
||||||
|
@@index([firstName])
|
||||||
|
@@index([lastName])
|
||||||
}
|
}
|
||||||
|
|
||||||
model CustomerAddress {
|
model CustomerAddress {
|
||||||
@@ -329,6 +482,11 @@ model ProductImage {
|
|||||||
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
|
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
|
||||||
/// existing rows and any generic "just attach an image" caller keep working.
|
/// existing rows and any generic "just attach an image" caller keep working.
|
||||||
role ProductImageRole @default(GALLERY)
|
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 {
|
enum ProductImageRole {
|
||||||
@@ -356,7 +514,16 @@ model ProductVariant {
|
|||||||
isPreOrder Boolean @default(false)
|
isPreOrder Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
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[]
|
orderItems OrderItem[]
|
||||||
|
|
||||||
|
@@index([imageId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -861,8 +1028,71 @@ model UploadedFile {
|
|||||||
uploadedByAdminUserId String?
|
uploadedByAdminUserId String?
|
||||||
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id])
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
customerAvatars Customer[] @relation("CustomerAvatar")
|
customerAvatars Customer[] @relation("CustomerAvatar")
|
||||||
|
adminUserAvatars AdminUser[] @relation("AdminUserAvatar")
|
||||||
|
|
||||||
@@index([purpose])
|
@@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
|
||||||
|
}
|
||||||
|
|||||||
+42
-4
@@ -133,6 +133,8 @@ exports.Prisma.CityScalarFieldEnum = {
|
|||||||
exports.Prisma.AdminUserScalarFieldEnum = {
|
exports.Prisma.AdminUserScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
passwordHash: 'passwordHash',
|
passwordHash: 'passwordHash',
|
||||||
role: 'role',
|
role: 'role',
|
||||||
@@ -140,12 +142,31 @@ exports.Prisma.AdminUserScalarFieldEnum = {
|
|||||||
branchId: 'branchId',
|
branchId: 'branchId',
|
||||||
wagePercent: 'wagePercent',
|
wagePercent: 'wagePercent',
|
||||||
isActive: 'isActive',
|
isActive: 'isActive',
|
||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt',
|
||||||
|
avatarFileId: 'avatarFileId',
|
||||||
|
address: 'address',
|
||||||
|
storeName: 'storeName',
|
||||||
|
pasazhName: 'pasazhName',
|
||||||
|
memberCode: 'memberCode'
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.Prisma.SalesExpertProfileScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
adminUserId: 'adminUserId',
|
||||||
|
cityId: 'cityId',
|
||||||
|
gramLevel: 'gramLevel',
|
||||||
|
phone: 'phone',
|
||||||
|
nationalCode: 'nationalCode',
|
||||||
|
tradingCustomerId: 'tradingCustomerId',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.CustomerScalarFieldEnum = {
|
exports.Prisma.CustomerScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
fullName: 'fullName',
|
fullName: 'fullName',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
nationalCode: 'nationalCode',
|
nationalCode: 'nationalCode',
|
||||||
passwordHash: 'passwordHash',
|
passwordHash: 'passwordHash',
|
||||||
@@ -161,6 +182,7 @@ exports.Prisma.CustomerScalarFieldEnum = {
|
|||||||
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
goldCreditLimitGrams: 'goldCreditLimitGrams',
|
||||||
goldDebtGrams: 'goldDebtGrams',
|
goldDebtGrams: 'goldDebtGrams',
|
||||||
fiatDebtRial: 'fiatDebtRial',
|
fiatDebtRial: 'fiatDebtRial',
|
||||||
|
makingChargeOffsetPercent: 'makingChargeOffsetPercent',
|
||||||
verificationStep: 'verificationStep',
|
verificationStep: 'verificationStep',
|
||||||
nationalIdImageUrl: 'nationalIdImageUrl',
|
nationalIdImageUrl: 'nationalIdImageUrl',
|
||||||
businessLicenseUrl: 'businessLicenseUrl',
|
businessLicenseUrl: 'businessLicenseUrl',
|
||||||
@@ -256,7 +278,8 @@ exports.Prisma.ProductVariantScalarFieldEnum = {
|
|||||||
defaultWagePercent: 'defaultWagePercent',
|
defaultWagePercent: 'defaultWagePercent',
|
||||||
qty: 'qty',
|
qty: 'qty',
|
||||||
isPreOrder: 'isPreOrder',
|
isPreOrder: 'isPreOrder',
|
||||||
isActive: 'isActive'
|
isActive: 'isActive',
|
||||||
|
imageId: 'imageId'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.OrderScalarFieldEnum = {
|
exports.Prisma.OrderScalarFieldEnum = {
|
||||||
@@ -491,6 +514,18 @@ exports.Prisma.UploadedFileScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt'
|
createdAt: 'createdAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.CustomerOtpScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
mobile: 'mobile',
|
||||||
|
customerId: 'customerId',
|
||||||
|
codeHash: 'codeHash',
|
||||||
|
expiresAt: 'expiresAt',
|
||||||
|
attempts: 'attempts',
|
||||||
|
consumedAt: 'consumedAt',
|
||||||
|
requestIp: 'requestIp',
|
||||||
|
createdAt: 'createdAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.SortOrder = {
|
exports.Prisma.SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -528,7 +563,8 @@ exports.CustomerKind = exports.$Enums.CustomerKind = {
|
|||||||
APP_SHOPKEEPER: 'APP_SHOPKEEPER',
|
APP_SHOPKEEPER: 'APP_SHOPKEEPER',
|
||||||
WALK_IN_SHOPKEEPER: 'WALK_IN_SHOPKEEPER',
|
WALK_IN_SHOPKEEPER: 'WALK_IN_SHOPKEEPER',
|
||||||
BANKER: 'BANKER',
|
BANKER: 'BANKER',
|
||||||
WORKSHOP_SUPERVISOR: 'WORKSHOP_SUPERVISOR'
|
WORKSHOP_SUPERVISOR: 'WORKSHOP_SUPERVISOR',
|
||||||
|
RETAIL: 'RETAIL'
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.VerificationStep = exports.$Enums.VerificationStep = {
|
exports.VerificationStep = exports.$Enums.VerificationStep = {
|
||||||
@@ -621,6 +657,7 @@ exports.MessageAudience = exports.$Enums.MessageAudience = {
|
|||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
City: 'City',
|
City: 'City',
|
||||||
AdminUser: 'AdminUser',
|
AdminUser: 'AdminUser',
|
||||||
|
SalesExpertProfile: 'SalesExpertProfile',
|
||||||
Customer: 'Customer',
|
Customer: 'Customer',
|
||||||
CustomerAddress: 'CustomerAddress',
|
CustomerAddress: 'CustomerAddress',
|
||||||
Branch: 'Branch',
|
Branch: 'Branch',
|
||||||
@@ -653,7 +690,8 @@ exports.Prisma.ModelName = {
|
|||||||
NotificationRecipient: 'NotificationRecipient',
|
NotificationRecipient: 'NotificationRecipient',
|
||||||
NewsletterSubscriber: 'NewsletterSubscriber',
|
NewsletterSubscriber: 'NewsletterSubscriber',
|
||||||
CustomerActivityLog: 'CustomerActivityLog',
|
CustomerActivityLog: 'CustomerActivityLog',
|
||||||
UploadedFile: 'UploadedFile'
|
UploadedFile: 'UploadedFile',
|
||||||
|
CustomerOtp: 'CustomerOtp'
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* The whole delivery flow, against the DOCKER COMPOSE stack.
|
||||||
|
*
|
||||||
|
* What the owner asked to see proven inside compose:
|
||||||
|
* 1. a sales expert signs into the app with the master code
|
||||||
|
* 2. an ordinary customer signs into the app with the master code
|
||||||
|
* 3. BOTH place an order
|
||||||
|
* 4. an ADMIN sees both of those orders in the admin panel
|
||||||
|
*
|
||||||
|
* Unlike the other scripts this one talks ONLY over HTTP — it never opens a
|
||||||
|
* Prisma client — because the point is to exercise the deployed container as a
|
||||||
|
* black box, including its own boot-time migration run. Setup and teardown go
|
||||||
|
* through the admin API, the same way an operator would.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/test-compose-e2e.cjs [apiBaseUrl] [masterCode]
|
||||||
|
* e.g. node scripts/test-compose-e2e.cjs http://localhost:3100/api 1379
|
||||||
|
*/
|
||||||
|
const API = (process.argv[2] || 'http://localhost:3100/api').replace(/\/$/, '');
|
||||||
|
const MASTER = process.argv[3] || '1379';
|
||||||
|
const ADMIN_USER = process.argv[4] || '09128081392';
|
||||||
|
const ADMIN_PASS = process.argv[5] || '09128081392';
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const failures = [];
|
||||||
|
const chk = (c, m) => {
|
||||||
|
if (c) { pass++; console.log(' PASS ' + m); }
|
||||||
|
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
|
||||||
|
};
|
||||||
|
const step = (t) => console.log('\n' + t);
|
||||||
|
|
||||||
|
const call = async (method, p, body, token) => {
|
||||||
|
const res = await fetch(`${API}/${p}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let payload = null;
|
||||||
|
try { payload = JSON.parse(text); } catch { /* empty */ }
|
||||||
|
return { status: res.status, ok: res.ok, body: payload, raw: text };
|
||||||
|
};
|
||||||
|
|
||||||
|
const signIn = (mobile) =>
|
||||||
|
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
|
||||||
|
|
||||||
|
const uniq = String(Date.now()).slice(-7);
|
||||||
|
const made = { customers: [], experts: [], orders: [] };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// ── ADMIN ───────────────────────────────────────────────────────────────
|
||||||
|
step('0 — ADMIN signs into the panel');
|
||||||
|
const adminLogin = await call('POST', 'admin/auth/login', {
|
||||||
|
phone: ADMIN_USER,
|
||||||
|
password: ADMIN_PASS,
|
||||||
|
});
|
||||||
|
chk(adminLogin.ok, `POST admin/auth/login -> ${adminLogin.status}`);
|
||||||
|
const adminToken = adminLogin.body?.api_token;
|
||||||
|
if (!adminToken) {
|
||||||
|
console.log('\n Could not sign in as admin; pass the credentials as argv[4]/argv[5].');
|
||||||
|
console.log(' Response: ' + adminLogin.raw.slice(0, 300));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
chk(Boolean(adminToken), 'an admin token came back');
|
||||||
|
|
||||||
|
// ── SETUP through the ADMIN API, as an operator would ───────────────────
|
||||||
|
step('SETUP — create a sales expert and a customer through the admin panel');
|
||||||
|
|
||||||
|
const expertMobile = `0913${uniq}`;
|
||||||
|
const expert = await call('POST', 'admin/users', {
|
||||||
|
fname: 'کارشناس', lname: 'کامپوز',
|
||||||
|
mobile: expertMobile, password: 'Test12345',
|
||||||
|
role: 'SALES_EXPERT', percent: 3.5, gram_level: 150,
|
||||||
|
}, adminToken);
|
||||||
|
chk(expert.ok, `create the sales expert -> ${expert.status}` + (expert.ok ? '' : ' ' + expert.raw.slice(0, 160)));
|
||||||
|
if (expert.body?.id) made.experts.push(expert.body.id);
|
||||||
|
|
||||||
|
const custMobile = `0914${uniq}`;
|
||||||
|
const cust = await call('POST', 'admin/customers-omde/create', {
|
||||||
|
fname: 'مشتری', lname: 'کامپوز', name: 'مشتری کامپوز',
|
||||||
|
mobile: custMobile, percent: 3, type: 2,
|
||||||
|
store_name: 'گالری کامپوز', gram_level: 200,
|
||||||
|
}, adminToken);
|
||||||
|
chk(cust.ok, `create the customer -> ${cust.status}` + (cust.ok ? '' : ' ' + cust.raw.slice(0, 160)));
|
||||||
|
const custId = cust.body?.id;
|
||||||
|
if (custId) made.customers.push(custId);
|
||||||
|
|
||||||
|
// A product to order.
|
||||||
|
step('SETUP — find an orderable product variant');
|
||||||
|
const products = await call('GET', 'catalog/products');
|
||||||
|
chk(products.ok, `GET catalog/products -> ${products.status}`);
|
||||||
|
const variant = (products.body ?? [])
|
||||||
|
.flatMap((p) => p.variants ?? [])
|
||||||
|
.find((v) => v && v.id);
|
||||||
|
chk(Boolean(variant), `found a variant to order (${variant?.id ?? 'none'})`);
|
||||||
|
if (!variant) {
|
||||||
|
console.log(' No product variants in this database — cannot place orders.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. THE EXPERT SIGNS IN ──────────────────────────────────────────────
|
||||||
|
step('1 — THE SALES EXPERT signs into the app with the master code');
|
||||||
|
const expReq = await call('POST', 'customer/auth/request-otp', { username: expertMobile });
|
||||||
|
chk(expReq.ok, `request-otp -> ${expReq.status}`);
|
||||||
|
|
||||||
|
const expLogin = await signIn(expertMobile);
|
||||||
|
chk(expLogin.ok, `verify-otp with the master code -> ${expLogin.status}`);
|
||||||
|
const expToken = expLogin.body?.token;
|
||||||
|
chk(Boolean(expToken), 'an app token came back');
|
||||||
|
|
||||||
|
const whoami = await call('GET', 'expert/whoami', undefined, expToken);
|
||||||
|
chk(whoami.body?.isSalesExpert === true,
|
||||||
|
`the app is told this session is an expert (${whoami.body?.isSalesExpert})`);
|
||||||
|
|
||||||
|
// ── 2. THE CUSTOMER SIGNS IN ────────────────────────────────────────────
|
||||||
|
step('2 — AN ORDINARY CUSTOMER signs into the app with the master code');
|
||||||
|
await call('POST', 'customer/auth/request-otp', { username: custMobile });
|
||||||
|
const custLogin = await signIn(custMobile);
|
||||||
|
chk(custLogin.ok, `verify-otp with the master code -> ${custLogin.status}`);
|
||||||
|
const custToken = custLogin.body?.token;
|
||||||
|
chk(Boolean(custToken), 'an app token came back');
|
||||||
|
|
||||||
|
const custWho = await call('GET', 'expert/whoami', undefined, custToken);
|
||||||
|
chk(custWho.body?.isSalesExpert === false,
|
||||||
|
`and is NOT flagged as an expert (${custWho.body?.isSalesExpert})`);
|
||||||
|
|
||||||
|
// ── 3. BOTH PLACE AN ORDER ──────────────────────────────────────────────
|
||||||
|
step('3 — BOTH place an order from the app');
|
||||||
|
|
||||||
|
// The exact payload both apps send from the cart (CartView.vue:200-211):
|
||||||
|
// wagePercent comes from the variant the cart pinned. No customerId — the
|
||||||
|
// server takes the subject from the token (D21).
|
||||||
|
const placeOrder = async (token, who) => {
|
||||||
|
const r = await call('POST', 'orders', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productVariantId: variant.id,
|
||||||
|
quantity: 1,
|
||||||
|
wagePercent: parseFloat(variant.defaultWagePercent ?? '0'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, token);
|
||||||
|
chk(r.ok, `${who} places an order -> ${r.status}${r.ok ? ' (' + r.body?.orderNumber + ')' : ' ' + r.raw.slice(0, 160)}`);
|
||||||
|
if (r.body?.id) made.orders.push({ id: r.body.id, number: r.body.orderNumber, who });
|
||||||
|
return r.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expOrder = await placeOrder(expToken, 'the EXPERT');
|
||||||
|
const custOrder = await placeOrder(custToken, 'the CUSTOMER');
|
||||||
|
|
||||||
|
chk(Boolean(expOrder?.orderNumber && custOrder?.orderNumber),
|
||||||
|
'both orders came back with an order number');
|
||||||
|
chk(expOrder?.orderNumber !== custOrder?.orderNumber,
|
||||||
|
'and they are two distinct orders');
|
||||||
|
|
||||||
|
// Each sees their own order in their own history, and only their own.
|
||||||
|
const expHistory = await call('GET', 'customer/me/orders', undefined, expToken);
|
||||||
|
chk(expHistory.body?.some((o) => o.id === expOrder?.id),
|
||||||
|
"the expert's own order is in the expert's history");
|
||||||
|
chk(!expHistory.body?.some((o) => o.id === custOrder?.id),
|
||||||
|
"and the customer's order is NOT");
|
||||||
|
|
||||||
|
const custHistory = await call('GET', 'customer/me/orders', undefined, custToken);
|
||||||
|
chk(custHistory.body?.some((o) => o.id === custOrder?.id),
|
||||||
|
"the customer's own order is in the customer's history");
|
||||||
|
chk(!custHistory.body?.some((o) => o.id === expOrder?.id),
|
||||||
|
"and the expert's order is NOT");
|
||||||
|
|
||||||
|
// ── 4. THE ADMIN SEES BOTH ──────────────────────────────────────────────
|
||||||
|
step('4 — THE ADMIN sees both orders in the panel');
|
||||||
|
const list = await call('POST', 'admin/orders/list', { page: 1, limit: 100 }, adminToken);
|
||||||
|
chk(list.ok, `POST admin/orders/list -> ${list.status}`);
|
||||||
|
|
||||||
|
const rows = list.body?.rows ?? list.body?.data ?? list.body?.result ?? [];
|
||||||
|
chk(Array.isArray(rows) && rows.length > 0, `the grid returned ${rows.length} row(s)`);
|
||||||
|
|
||||||
|
const findRow = (id, number) =>
|
||||||
|
rows.find((r) => r.id === id || r.orderNumber === number);
|
||||||
|
|
||||||
|
chk(Boolean(findRow(expOrder?.id, expOrder?.orderNumber)),
|
||||||
|
`the EXPERT's order ${expOrder?.orderNumber} appears in the admin grid`);
|
||||||
|
chk(Boolean(findRow(custOrder?.id, custOrder?.orderNumber)),
|
||||||
|
`the CUSTOMER's order ${custOrder?.orderNumber} appears in the admin grid`);
|
||||||
|
|
||||||
|
// And the admin can open each one.
|
||||||
|
for (const o of made.orders) {
|
||||||
|
const detail = await call('GET', `orders/admin/${o.id}`, undefined, adminToken);
|
||||||
|
chk(detail.ok, `the admin can open ${o.who}'s order ${o.number} -> ${detail.status}`);
|
||||||
|
chk(detail.body?.items?.length > 0, `and it carries its items (${detail.body?.items?.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. TOKEN BOUNDARIES still hold inside compose ───────────────────────
|
||||||
|
step('5 — the token boundaries hold in the deployed container too');
|
||||||
|
const appOnAdmin = await call('POST', 'admin/orders/list', { page: 1 }, expToken);
|
||||||
|
chk(appOnAdmin.status === 401 || appOnAdmin.status === 403,
|
||||||
|
`an expert's APP token on an admin route -> ${appOnAdmin.status}`);
|
||||||
|
|
||||||
|
const custOnExpert = await call('POST', 'expert/my-customers', { search: null }, custToken);
|
||||||
|
chk(custOnExpert.status === 403 || custOnExpert.status === 401,
|
||||||
|
`a customer's token on an expert route -> ${custOnExpert.status}`);
|
||||||
|
|
||||||
|
const adminOnApp = await call('GET', 'customer/me', undefined, adminToken);
|
||||||
|
chk(adminOnApp.status === 401, `an admin token on an app route -> ${adminOnApp.status}`);
|
||||||
|
|
||||||
|
// ── CLEANUP ─────────────────────────────────────────────────────────────
|
||||||
|
step('CLEANUP');
|
||||||
|
console.log(' This run created, through the admin API:');
|
||||||
|
for (const o of made.orders) console.log(` order ${o.number} (${o.who})`);
|
||||||
|
console.log(` customer ${custMobile}, sales expert ${expertMobile}`);
|
||||||
|
console.log(' They are LEFT IN PLACE deliberately: this is the compose stack, its');
|
||||||
|
console.log(' database is separate from development, and the rows are what the');
|
||||||
|
console.log(' owner will look at in the panel. Delete them from the admin panel');
|
||||||
|
console.log(' after the demo if they are not wanted.');
|
||||||
|
|
||||||
|
console.log(`\n${'='.repeat(60)}`);
|
||||||
|
console.log(`COMPOSE END-TO-END: passed ${pass} failed ${fail}`);
|
||||||
|
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error('\nABORTED:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { envOr, envOrNull } from './env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPLOY-02 — a blank environment variable must be treated as unset.
|
||||||
|
*
|
||||||
|
* `docker-compose.yml` passes every optional variable as `${X:-}`, so an
|
||||||
|
* unconfigured one arrives as `""`, not `undefined`. The codebase originally
|
||||||
|
* read these with `process.env.X ?? 'default'`, which only falls back on
|
||||||
|
* `undefined` — so the empty string won, and
|
||||||
|
*
|
||||||
|
* new JwtService().sign(payload, { expiresIn: '' })
|
||||||
|
*
|
||||||
|
* threw `"expiresIn" should be a number of seconds or string representing a
|
||||||
|
* timespan` on EVERY login. In Docker only, which is why it survived every
|
||||||
|
* suite that ran against a local dev process. These tests pin the behaviour.
|
||||||
|
*/
|
||||||
|
describe('envOr / envOrNull (DEPLOY-02)', () => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
const KEY = 'GOLDBANK_ENV_SPEC_KEY';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env[KEY];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = { ...saved };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('envOr', () => {
|
||||||
|
it('falls back when the variable is absent', () => {
|
||||||
|
expect(envOr(KEY, '12h')).toBe('12h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when the variable is the EMPTY STRING — the compose case', () => {
|
||||||
|
process.env[KEY] = '';
|
||||||
|
expect(envOr(KEY, '12h')).toBe('12h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when the variable is only whitespace', () => {
|
||||||
|
process.env[KEY] = ' ';
|
||||||
|
expect(envOr(KEY, '12h')).toBe('12h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the value when one is set', () => {
|
||||||
|
process.env[KEY] = '30d';
|
||||||
|
expect(envOr(KEY, '12h')).toBe('30d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims a padded value rather than passing the padding through', () => {
|
||||||
|
process.env[KEY] = ' 30d ';
|
||||||
|
expect(envOr(KEY, '12h')).toBe('30d');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns an empty string, whatever is set', () => {
|
||||||
|
for (const v of ['', ' ', '\t', '\n', ' \t\n ']) {
|
||||||
|
process.env[KEY] = v;
|
||||||
|
expect(envOr(KEY, 'fallback')).not.toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('envOrNull', () => {
|
||||||
|
it('is null when absent, empty or blank', () => {
|
||||||
|
expect(envOrNull(KEY)).toBeNull();
|
||||||
|
process.env[KEY] = '';
|
||||||
|
expect(envOrNull(KEY)).toBeNull();
|
||||||
|
process.env[KEY] = ' ';
|
||||||
|
expect(envOrNull(KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the trimmed value when set', () => {
|
||||||
|
process.env[KEY] = ' secret ';
|
||||||
|
expect(envOrNull(KEY)).toBe('secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The specific failure: an empty expiresIn must never reach jsonwebtoken.
|
||||||
|
* Asserted against the real library so the test fails if the reader regresses.
|
||||||
|
*/
|
||||||
|
describe('the original failure', () => {
|
||||||
|
it('jsonwebtoken rejects an empty expiresIn, and envOr never produces one', () => {
|
||||||
|
// Required rather than imported: jsonwebtoken is a transitive dependency
|
||||||
|
// of @nestjs/jwt and ships no types here. Typed loosely on purpose — this
|
||||||
|
// test is about the VALUE reaching it, not about its signature.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const jwt = require('jsonwebtoken') as {
|
||||||
|
sign: (payload: object, secret: string, opts: { expiresIn: string }) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bug: this is exactly what `process.env.JWT_EXPIRES_IN ?? '12h'`
|
||||||
|
// produced under compose, and it threw on every login.
|
||||||
|
expect(() => jwt.sign({ sub: 'x' }, 'secret', { expiresIn: '' })).toThrow(
|
||||||
|
/expiresIn/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The fix: envOr turns that same blank into the default, which signs.
|
||||||
|
process.env[KEY] = '';
|
||||||
|
const token = jwt.sign({ sub: 'x' }, 'secret', { expiresIn: envOr(KEY, '12h') });
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Environment readers that treat a BLANK value as unset.
|
||||||
|
*
|
||||||
|
* `process.env.X ?? 'default'` only falls back when the variable is absent, so
|
||||||
|
* an empty string sails straight through. That matters here because
|
||||||
|
* `docker-compose.yml` passes every optional variable as `${X:-}`, which sets
|
||||||
|
* it to `""` when the operator has not configured it. jsonwebtoken then throws
|
||||||
|
*
|
||||||
|
* "expiresIn" should be a number of seconds or string representing a timespan
|
||||||
|
*
|
||||||
|
* and EVERY login 500s — in Docker only, which is why it survived every test
|
||||||
|
* that ran against a local dev process. Found by the pre-delivery compose run;
|
||||||
|
* recorded as DEPLOY-02.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The variable's trimmed value, or `fallback` when it is unset, empty or blank. */
|
||||||
|
export function envOr(name: string, fallback: string): string {
|
||||||
|
const raw = process.env[name];
|
||||||
|
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
return trimmed === '' ? fallback : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The variable's trimmed value, or null when it is unset, empty or blank. */
|
||||||
|
export function envOrNull(name: string): string | null {
|
||||||
|
const raw = process.env[name];
|
||||||
|
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
return trimmed === '' ? null : trimmed;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { AppModule } from './app.module';
|
|||||||
import { prepareDatabase } from './prisma/bootstrap';
|
import { prepareDatabase } from './prisma/bootstrap';
|
||||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||||
import { assertSmsConfigured } from './modules/sms/sms.service';
|
import { assertSmsConfigured } from './modules/sms/sms.service';
|
||||||
|
import { envOr, envOrNull } from './common/env';
|
||||||
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
|
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
|
||||||
|
|
||||||
// serve-static's bundled `mime` version predates .avif, so it falls back to
|
// serve-static's bundled `mime` version predates .avif, so it falls back to
|
||||||
@@ -29,8 +30,8 @@ async function bootstrap() {
|
|||||||
// and a sales expert's app token carries their AdminUser id in `sub`. Warn as
|
// and a sales expert's app token carries their AdminUser id in `sub`. Warn as
|
||||||
// loudly as the master-code banner rather than fail the boot, so a running
|
// loudly as the master-code banner rather than fail the boot, so a running
|
||||||
// deployment that has not set it yet still starts.
|
// deployment that has not set it yet still starts.
|
||||||
if (!process.env.JWT_CUSTOMER_SECRET?.trim()) {
|
if (!envOrNull('JWT_CUSTOMER_SECRET')) {
|
||||||
const sharedWithAdmin = Boolean(process.env.JWT_SECRET?.trim());
|
const sharedWithAdmin = Boolean(envOrNull('JWT_SECRET'));
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn(
|
console.warn(
|
||||||
'\n' +
|
'\n' +
|
||||||
@@ -87,7 +88,7 @@ async function bootstrap() {
|
|||||||
app.enableCors();
|
app.enableCors();
|
||||||
// Uploaded files, mounted OUTSIDE the /api prefix so the URLs stored on
|
// Uploaded files, mounted OUTSIDE the /api prefix so the URLs stored on
|
||||||
// UploadedFile.url ("/uploads/<name>") resolve directly.
|
// UploadedFile.url ("/uploads/<name>") resolve directly.
|
||||||
app.useStaticAssets(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'), {
|
app.useStaticAssets(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')), {
|
||||||
prefix: '/uploads/',
|
prefix: '/uploads/',
|
||||||
index: false,
|
index: false,
|
||||||
dotfiles: 'deny',
|
dotfiles: 'deny',
|
||||||
@@ -96,7 +97,9 @@ async function bootstrap() {
|
|||||||
if (mime) res.setHeader('Content-Type', mime);
|
if (mime) res.setHeader('Content-Type', mime);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
// envOr, not `??`: Number('') is 0, which would bind a random ephemeral port
|
||||||
|
// and make the container unreachable at the port compose maps. See DEPLOY-02.
|
||||||
|
const port = Number(envOr('PORT', '3000'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Bind all interfaces explicitly so the container is reachable from nginx.
|
// Bind all interfaces explicitly so the container is reachable from nginx.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync } from 'node:fs';
|
||||||
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
||||||
|
import { envOr } from '../../common/env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directory files are written to and served from.
|
* Directory files are written to and served from.
|
||||||
@@ -10,7 +11,7 @@ import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|||||||
* directory and the previous static root then 404'd every file.
|
* directory and the previous static root then 404'd every file.
|
||||||
*/
|
*/
|
||||||
export function getUploadDir(): string {
|
export function getUploadDir(): string {
|
||||||
return resolve(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'));
|
return resolve(envOr('UPLOAD_DIR', join(process.cwd(), 'storage', 'uploads')));
|
||||||
}
|
}
|
||||||
|
|
||||||
const UPLOAD_MIME: Record<string, string> = {
|
const UPLOAD_MIME: Record<string, string> = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { APP_GUARD } from '@nestjs/core';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
import { PrismaModule } from '../../prisma/prisma.module';
|
import { PrismaModule } from '../../prisma/prisma.module';
|
||||||
|
import { envOr } from '../../common/env';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
@@ -13,11 +14,15 @@ import { JwtAuthGuard, PermissionsGuard } from './guards';
|
|||||||
PrismaModule,
|
PrismaModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
// envOr, not `??`: docker-compose passes every optional variable as
|
||||||
|
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
|
||||||
|
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
|
||||||
|
// admin login in Docker only. See DEPLOY-02.
|
||||||
|
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||||
// `string` from process.env does not satisfy; the cast is the documented escape.
|
// `string` from process.env does not satisfy; the cast is the documented escape.
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`,
|
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { envOr } from '../../common/env';
|
||||||
import { expandWithParents } from './acl.constants';
|
import { expandWithParents } from './acl.constants';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
@@ -49,7 +50,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret',
|
secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
import { AdminRole, CustomerKind, MessageAudience, Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { envOr, envOrNull } from '../../common/env';
|
||||||
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
|
||||||
import { faToEn } from './digits';
|
import { faToEn } from './digits';
|
||||||
import { SmsService, smsConfigured } from '../sms/sms.service';
|
import { SmsService, smsConfigured } from '../sms/sms.service';
|
||||||
@@ -386,7 +387,7 @@ export class CustomerAuthService {
|
|||||||
};
|
};
|
||||||
return this.jwt.signAsync(payload, {
|
return this.jwt.signAsync(payload, {
|
||||||
secret: customerJwtSecret(),
|
secret: customerJwtSecret(),
|
||||||
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +409,7 @@ export class CustomerAuthService {
|
|||||||
secret: customerJwtSecret(),
|
secret: customerJwtSecret(),
|
||||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||||
// string from process.env does not satisfy — same cast AuthModule uses.
|
// string from process.env does not satisfy — same cast AuthModule uses.
|
||||||
expiresIn: (process.env.JWT_CUSTOMER_EXPIRES_IN ?? '30d') as `${number}d`,
|
expiresIn: envOr('JWT_CUSTOMER_EXPIRES_IN', '30d') as `${number}d`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,8 +513,8 @@ export class CustomerAuthService {
|
|||||||
*/
|
*/
|
||||||
export function customerJwtSecret(): string {
|
export function customerJwtSecret(): string {
|
||||||
return (
|
return (
|
||||||
process.env.JWT_CUSTOMER_SECRET ??
|
envOrNull('JWT_CUSTOMER_SECRET') ??
|
||||||
process.env.JWT_SECRET ??
|
envOrNull('JWT_SECRET') ??
|
||||||
'dev-only-insecure-customer-secret'
|
'dev-only-insecure-customer-secret'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { MessageAudience } from '@prisma/client';
|
import { MessageAudience } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { envOr } from '../../common/env';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
* SMS delivery — port of henza_server/app/Models/Sms.php.
|
||||||
@@ -49,8 +50,8 @@ export interface SmsSendOptions {
|
|||||||
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
/** Reads the provider config. Exported so main.ts can validate it at boot. */
|
||||||
export function smsConfig() {
|
export function smsConfig() {
|
||||||
return {
|
return {
|
||||||
provider: (process.env.SMS_PROVIDER ?? 'trez').toLowerCase(),
|
provider: envOr('SMS_PROVIDER', 'trez').toLowerCase(),
|
||||||
url: process.env.SMS_TREZ_URL ?? DEFAULT_TREZ_URL,
|
url: envOr('SMS_TREZ_URL', DEFAULT_TREZ_URL),
|
||||||
username: process.env.SMS_TREZ_USERNAME ?? '',
|
username: process.env.SMS_TREZ_USERNAME ?? '',
|
||||||
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
password: process.env.SMS_TREZ_PASSWORD ?? '',
|
||||||
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
senderLine: process.env.SMS_TREZ_SENDER_LINE ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Temporary overlay used ONLY to verify the compose stack end to end before
|
||||||
|
# delivery. It publishes the API port to the host so the test script can reach
|
||||||
|
# it; the real deployment keeps `expose` and routes by domain. Not part of the
|
||||||
|
# deployment — start it explicitly with `-f docker-compose.yml -f this`.
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
ports:
|
||||||
|
- "3100:3000"
|
||||||
@@ -47,6 +47,9 @@ services:
|
|||||||
- SMS_TREZ_USERNAME=${SMS_TREZ_USERNAME:-}
|
- SMS_TREZ_USERNAME=${SMS_TREZ_USERNAME:-}
|
||||||
- SMS_TREZ_PASSWORD=${SMS_TREZ_PASSWORD:-}
|
- SMS_TREZ_PASSWORD=${SMS_TREZ_PASSWORD:-}
|
||||||
- SMS_TREZ_SENDER_LINE=${SMS_TREZ_SENDER_LINE:-}
|
- SMS_TREZ_SENDER_LINE=${SMS_TREZ_SENDER_LINE:-}
|
||||||
|
# Admin mobiles told when a customer completes their profile (D22).
|
||||||
|
# Blank = nobody is notified; the upload still succeeds.
|
||||||
|
- PROFILE_ADMIN_MOBILES=${PROFILE_ADMIN_MOBILES:-}
|
||||||
# No host ports — Dokploy/Traefik routes by domain to container port 3000
|
# No host ports — Dokploy/Traefik routes by domain to container port 3000
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
|
|||||||
@@ -1338,3 +1338,26 @@ than rendering a fabricated value. Recorded as REP-01.
|
|||||||
The reference gated the page on `myStore.hasMCode()`; with no mcode in the
|
The reference gated the page on `myStore.hasMCode()`; with no mcode in the
|
||||||
target the gate is simply a session, and the server scopes every row to the
|
target the gate is simply a session, and the server scopes every row to the
|
||||||
token.
|
token.
|
||||||
|
|
||||||
|
## D25 — blank env vars are treated as unset, 2026-09-23
|
||||||
|
|
||||||
|
Found by the pre-delivery compose run (DEPLOY-02): **admin login returned 500
|
||||||
|
for every user in Docker.** `docker-compose.yml` passes each optional variable
|
||||||
|
as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code
|
||||||
|
read them with `process.env.X ?? 'default'`, which only falls back on
|
||||||
|
`undefined` — so the empty string won and `jsonwebtoken` threw on every sign.
|
||||||
|
|
||||||
|
Invisible to every existing suite, all of which ran against a local dev process
|
||||||
|
where the variable is genuinely absent.
|
||||||
|
|
||||||
|
`src/common/env.ts` now provides `envOr` / `envOrNull`, which treat blank as
|
||||||
|
unset, and every auth, port, upload and SMS read uses them. Three of those were
|
||||||
|
not merely 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. Pinned by `src/common/env.spec.ts`, which reproduces the original throw
|
||||||
|
against the real `jsonwebtoken` and asserts the reader can no longer produce it.
|
||||||
|
|
||||||
|
**Standing rule from here on:** read an environment variable with `envOr` /
|
||||||
|
`envOrNull`, never with `??`, unless an empty string is a meaningful value for
|
||||||
|
that specific variable.
|
||||||
|
|||||||
@@ -165,3 +165,10 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
|||||||
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. |
|
| **REP-01** | Medium (documented gap) | REF external Tahesab system vs TGT `LedgerEntry` | The reference's statement carried columns the target has no source for: foreign currency (`rial_V2`/`rial_Kh2`/`tahesabP2`), `vaznPlastik`, `nO_kar` (نام کارساخته), `jens_felez` (جنس فلز) and the external system's internal ids (`gid`/`rec`/`reg`). | `henza_pwa/src/type/accounting.ts`; `AccountingDetail.vue:34-46` | The API returns `null`/zero for these and the UI **omits** the row rather than showing a fabricated value. If the business needs them, they need columns on `LedgerEntry` first. |
|
||||||
| **REP-02** | Low (performance) | TGT `customer-report.service.ts` `summary()` | The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. | `summary()` `findMany` without `take` | If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time. |
|
| **REP-02** | Low (performance) | TGT `customer-report.service.ts` `summary()` | The running balance makes a row depend on every entry before it, so the whole confirmed ledger for the customer is folded in memory and the page sliced afterwards. Cheap at one customer's own document counts; linear in their history. | `summary()` `findMany` without `take` | If a customer ever accumulates enough entries for it to matter, move the fold to a stored running total written at entry time. |
|
||||||
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. |
|
| **SEC-17** | Medium (reference, not applicable to target) | REF `Customer/TahesabController.php` | The reference calls the external accounting system with `Http::withoutVerifying()` (TLS certificate verification **disabled**) against a bare IP, with a bearer token and an `sfx` secret hardcoded in the source and embedded in the URL. | `->withToken("S3F1P8…")`, `sfx=wcG97tkF…` in four URLs | Not applicable — the target makes no external call. Listed so it is not reintroduced if a Tahesab integration is ever added; the credentials in that reference file should be rotated regardless. |
|
||||||
|
|
||||||
|
## Found by the pre-delivery Docker Compose run (2026-09-23)
|
||||||
|
|
||||||
|
| ID | Severity | Where | Description | Evidence | Suggested fix |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **DEPLOY-02** | ~~**Critical**~~ **FIXED** | TGT `auth.module.ts`, `customer-auth.service.ts`, `jwt.strategy.ts`, `main.ts`, `upload-path.ts`, `sms.service.ts` | **Admin login returned 500 for every user in Docker.** `docker-compose.yml` passes each optional variable as `${X:-}`, so an unconfigured one arrives as `""`, not `undefined`. The code read them with `process.env.X ?? 'default'`, which only falls back on `undefined` — so the empty string won and `jsonwebtoken` threw `"expiresIn" should be a number of seconds or string representing a timespan` on **every** sign. Invisible to every suite that ran against a local dev process, where the variable is genuinely absent. The same pattern also meant a blank `UPLOAD_DIR` would have served static files from the **filesystem root**, and a blank `PORT` would have bound a random ephemeral port (`Number('') === 0`), making the container unreachable. | Container log: `ERROR [ExceptionsHandler] "expiresIn" should be a number of seconds or string representing a timespan` | ✅ Added `src/common/env.ts` (`envOr` / `envOrNull`) which treats blank as unset, and applied it to every auth, port, upload and SMS read. 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. Re-verified end to end in compose: **32/32**. |
|
||||||
|
| **DEPLOY-03** | Medium (process) | TGT `apps/api/Dockerfile` | The image is `COPY release/ ./` — it ships a **pre-built bundle** produced on the developer machine by `pnpm build:release`, not a build performed inside the image. So `docker compose build` happily produces an image from a **stale** `release/app.js` and reports success. At the start of this run that bundle was four days old, and the rebuilt container was missing every route added since. | `apps/api/release/app.js` dated Sep 19 while the source was Sep 23; the container mapped none of the new routes | **`pnpm build:release` MUST be run before `docker compose build`.** Noted in DELIVERY.md's deploy section. A longer-term fix is to build inside the Dockerfile, or to have the build fail when `release/` is older than `src/`. |
|
||||||
|
|||||||
Reference in New Issue
Block a user