diff --git a/.env.example b/.env.example index fd95cf1..dee8b96 100644 --- a/.env.example +++ b/.env.example @@ -152,3 +152,12 @@ UPLOAD_DIR= # instead so no personal number is committed. Blank means nobody is notified — # the upload and the customer's own SMS still go through (D20). PROFILE_ADMIN_MOBILES= + +# --------------------------------------------------------------------------- +# Order placement +# --------------------------------------------------------------------------- +# Comma-separated admin mobiles told when a customer places an order. The +# reference hardcoded four numbers (henza_server +# Api/ApiMobileController::setOrder:740-743). Blank means nobody is notified — +# the order, the customer's SMS and the sales expert's SMS still go through. +ORDER_ADMIN_MOBILES= diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts index 00237c7..652fdb7 100644 --- a/apps/api/src/modules/orders/orders.module.ts +++ b/apps/api/src/modules/orders/orders.module.ts @@ -4,9 +4,10 @@ import { OrdersController } from './orders.controller'; import { CatalogModule } from '../catalog/catalog.module'; import { AccountingModule } from '../accounting/accounting.module'; import { PricingModule } from '../pricing/pricing.module'; +import { SmsModule } from '../sms/sms.module'; @Module({ - imports: [CatalogModule, AccountingModule, PricingModule], + imports: [CatalogModule, AccountingModule, PricingModule, SmsModule], controllers: [OrdersController], providers: [OrdersService], exports: [OrdersService], diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index 395de63..d9fbe5d 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -1,6 +1,9 @@ import { Prisma } from '@prisma/client'; import { OrdersService } from './orders.service'; +/** Order SMS is fire-and-forget (D20); these suites only assert order data. */ +const smsStub = { send: jest.fn(async () => ({ ok: true })) }; + /** * Owner ruling 2026-10-08: an order is credited to the customer's expert AT * THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of @@ -26,12 +29,13 @@ describe('OrdersService.createOrder — sales expert credit', () => { goldCreditLimitGrams: new Prisma.Decimal(1000), })), }, + adminUser: { findUnique: jest.fn(async () => null) }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; const accounting = { orderStatusReached: jest.fn() }; const wage = { quote: jest.fn() }; - const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never); + const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never); return { service, created }; } @@ -72,7 +76,7 @@ describe('OrdersService.createOrder — order ban', () => { $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; - return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never); + return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never); } const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; @@ -99,7 +103,7 @@ describe('OrdersService.createOrder — server-side making charge', () => { $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), }; const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; - const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never); + const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never); return { service, created }; } // A tampered request: zero making charge, a 50% discount and a rial charge. @@ -119,3 +123,91 @@ describe('OrdersService.createOrder — server-side making charge', () => { expect(created[0].flatRialCharge.toString()).toBe('999'); }); }); + +/** + * Legacy ApiMobileController::setOrder:733-766 texted three audiences on every + * placed order. None of it was wired in the target until now. + */ +describe('OrdersService.createOrder — order SMS', () => { + function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) { + const sent: { to: string; body: string }[] = []; + const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } }; + const prisma = { + customer: { + findUnique: jest.fn(async () => ({ + id: 'c1', + mobile: '09120000001', + firstName: 'علی', + lastName: 'رضایی', + canPlaceOrders: true, + referredByAgentId: opts.agentId ?? null, + goldDebtGrams: new Prisma.Decimal(0), + goldCreditLimitGrams: new Prisma.Decimal(opts.overLimit ? 1 : 1000), + })), + }, + adminUser: { findUnique: jest.fn(async () => ({ mobile: opts.agentMobile ?? null })) }, + $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }; + const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; + const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) }; + const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never); + return { service, sent }; + } + + const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never; + // SMS is deliberately not awaited (D20), so let the microtasks drain. + const settle = () => new Promise((r) => setImmediate(r)); + + afterEach(() => { delete process.env.ORDER_ADMIN_MOBILES; }); + + it('texts the customer that the order was placed', async () => { + const { service, sent } = build(); + await service.createOrder(dto, 'c1', { placedBy: 'customer' }); + await settle(); + const toCustomer = sent.find((s) => s.to === '09120000001'); + expect(toCustomer?.body).toContain('به ثبت رسید. منتزر'); + expect(toCustomer?.body).not.toContain('معلق'); + }); + + it('tells the customer an over-limit order was suspended', async () => { + const { service, sent } = build({ overLimit: true }); + await service.createOrder(dto, 'c1', { placedBy: 'customer' }); + await settle(); + const toCustomer = sent.find((s) => s.to === '09120000001'); + expect(toCustomer?.body).toContain('عبور از سقف مجاز گرمی در حالت معلق قرار گرفت'); + }); + + it('texts every configured admin with the name and weight', async () => { + process.env.ORDER_ADMIN_MOBILES = '09121111111, 09122222222'; + const { service, sent } = build(); + await service.createOrder(dto, 'c1', { placedBy: 'customer' }); + await settle(); + const admins = sent.filter((s) => s.to.startsWith('0912') && s.to !== '09120000001'); + expect(admins.map((a) => a.to)).toEqual(['09121111111', '09122222222']); + expect(admins[0].body).toContain('علی رضایی'); + expect(admins[0].body).toContain('11'); + }); + + it('texts nobody extra when no admin numbers are configured', async () => { + const { service, sent } = build(); + await service.createOrder(dto, 'c1', { placedBy: 'customer' }); + await settle(); + expect(sent).toHaveLength(1); + expect(sent[0].to).toBe('09120000001'); + }); + + it("texts the customer's sales expert", async () => { + const { service, sent } = build({ agentId: 'agent-A', agentMobile: '09123333333' }); + await service.createOrder(dto, 'c1', { placedBy: 'customer' }); + await settle(); + const toAgent = sent.find((s) => s.to === '09123333333'); + expect(toAgent?.body).toContain('سفارشی توسط علی رضایی'); + }); + + it('places the order even when the expert has no mobile', async () => { + const { service, sent } = build({ agentId: 'agent-A', agentMobile: null }); + await expect(service.createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' }); + await settle(); + expect(sent).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index bd4c982..33e0665 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -6,6 +6,7 @@ import { LedgerService } from '../accounting/ledger.service'; import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { WageService } from '../pricing/wage.service'; +import { SmsService } from '../sms/sms.service'; interface ComputedItem { productVariantId: string; @@ -27,6 +28,7 @@ export class OrdersService { private readonly ledger: LedgerService, private readonly accounting: AccountingHooksService, private readonly wage: WageService, + private readonly sms: SmsService, ) {} /** @@ -122,7 +124,7 @@ export class OrdersService { .gt(customer.goldCreditLimitGrams); const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED; - return this.prisma.$transaction(async (tx) => { + const placed = await this.prisma.$transaction(async (tx) => { const order = await tx.order.create({ data: { orderNumber: this.generateOrderNumber(), @@ -162,6 +164,87 @@ export class OrdersService { return order; }); + + // Legacy sent these after the order was committed + // (ApiMobileController.php:733-766), so a rolled-back order never texts + // anyone. Fire-and-forget per D20. + this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit); + + return placed; + } + + /** + * The admins who are told a new order was placed. + * + * Legacy hardcoded four numbers (ApiMobileController.php:740-743); this reads + * a comma-separated env var, as the complete-profile notifier already does. + * Unset means nobody is notified and the order itself still goes through. + */ + private adminMobiles(): string[] { + return (process.env.ORDER_ADMIN_MOBILES ?? '') + .split(',') + .map((m) => m.trim()) + .filter(Boolean); + } + + /** + * Legacy ApiMobileController::setOrder:733-766, brand changed to منتزر. + * + * Three audiences: the admins, the customer, and the customer's sales expert. + * Legacy's fourth message — «در سیستم ویرایش شد» (:734-737) — is + * «عمداً تکرار نشد»: no target path lets a customer edit a placed order, so + * that branch could never fire. + * + * `Notify::store` sat beside each of these in legacy (Firebase push); that is + * PUSH-01, deferred to the PWA port. + */ + private notifyOrderPlaced( + orderNumber: string, + customer: { mobile: string; firstName: string | null; lastName: string | null; referredByAgentId: string | null }, + totalGoldWeightGrams: Prisma.Decimal, + isSuspendedOverCreditLimit: boolean, + ) { + const fire = (to: string, body: string) => { + void this.sms.send(to, body).catch((e: unknown) => { + this.logger.error(`Order SMS to ${to} failed: ${String(e)}`); + }); + }; + + const name = `${customer.firstName ?? ''} ${customer.lastName ?? ''}`.trim(); + // Legacy printed Utility::round($allGram) — two decimals, trailing zeros + // dropped, which is what Decimal's own string form gives. + const weight = totalGoldWeightGrams.toDecimalPlaces(2).toString(); + + const adminMessage = `منتزر\nسفارشی توسط ${name}\n به وزن ${weight}\nدر سیستم ثبت شد.`; + for (const admin of this.adminMobiles()) fire(admin, adminMessage); + + fire( + customer.mobile, + isSuspendedOverCreditLimit + ? `سفارش شما به شماره ${orderNumber} به ثبت رسید اما به دلیل عبور از سقف مجاز گرمی در حالت معلق قرار گرفت. منتزر` + : `سفارش شما به شماره ${orderNumber} به ثبت رسید. منتزر`, + ); + + if (customer.referredByAgentId) { + void this.notifySalesAgent(customer.referredByAgentId, name, orderNumber, weight); + } + } + + /** Legacy :762-765 — the referring expert is texted too. */ + private async notifySalesAgent(agentId: string, customerName: string, orderNumber: string, weight: string) { + try { + const agent = await this.prisma.adminUser.findUnique({ + where: { id: agentId }, + select: { mobile: true }, + }); + if (!agent?.mobile) return; + await this.sms.send( + agent.mobile, + `منتزر\nسفارشی توسط ${customerName} به شماره ${orderNumber}و به وزن ${weight} به ثبت رسید.`, + ); + } catch (e: unknown) { + this.logger.error(`Order SMS to sales agent ${agentId} failed: ${String(e)}`); + } } async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) { diff --git a/docs/parity/01-ADMIN-MATRIX.md b/docs/parity/01-ADMIN-MATRIX.md index c885c8e..f6e10aa 100644 --- a/docs/parity/01-ADMIN-MATRIX.md +++ b/docs/parity/01-ADMIN-MATRIX.md @@ -138,12 +138,61 @@ prove no operation is left without an equivalent permission. Per-module mapping of the reference's admin controller operations onto the target's rid, grouped by the module of `apps/api/src/modules/`: -| Reference operation (controller) | Target endpoint | rid | -|---|---|---| -| *(pending — filled per module as each section below is audited)* | | | +Counted mechanically from the target's `@RequirePermission` decorators and the +permission tree, 2026-10-12 (§3–§9 are all complete, so both sides have been +read): -> Populated as §3 onward completes, so no row is written before both sides have -> been read. +| | Count | +|---|---| +| Reference admin routes (`routes/admin.php`) | 154 | +| Target admin routes carrying a rid | 163 | +| Distinct rids enforced by those routes | 68 | +| Keys in the permission tree (`permissionConst.ts`) | 176 | + +Per module, against the reference controller it ports: + +| Target module | Reference controller(s) | Routes | Distinct rids | +|---|---|---|---| +| `accounting` | `WalletController`, `CashController` | 5 | `wallet.list`, `wallet.charge`, `document.create`, `document.list.confirm` | +| `accounting-sync` | — target-only (D27) | 2 | `persons.list.client.omde`, `product.list` | +| `admin-branches` | `BranchController` | 6 | `branch.list`, `branch.create` | +| `admin-categories` | `CategoryController` | 11 | `collection.list`, `collection.create`, `collection.list.edit` | +| `admin-comments` | `CommentController` | 6 | `comment` | +| `admin-customers` | `AdminController`, `MajorCustomerController`, `SingleCustomerController` | 23 | 10 `persons.*` + `wallet` | +| `admin-dashboard` | `DashboardController` | 5 | `dashboard.view`, `report.order.item`, `report.product` | +| `admin-documents` | `CashController`, `WalletController` | 13 | `document.*`, `config.wallet`, `wallet` | +| `admin-invoices` | `CinyController` | 6 | `invoice.list`, `invoice.create` | +| `admin-misc` | `CitiesController`, `SmsController`, `NotifyController`, `ConstController`, `BrandController` | 27 | 16 (`cities.*`, `sms.*`, `notify.*`, `config.statics*`, `statics`, `newsletters`) | +| `admin-orders` | `OrderController`, `MajorOrderController` | 9 | `order.list`, `order.create`, `order.omde`, `order.tamin` | +| `admin-preorders` | `OrderController` (tamin) | 5 | `order.makeOrder` | +| `admin-products` | `ProductController` | 15 | `product.*`, `order.create` | +| `admin-promotions` | `PromotionController`, `IgnoreController` | 12 | `offers.*`, `offersIgnore` | +| `admin-report` | `ReportController` | 1 | `report.order.item` | +| `admin-users` | `UserController`, `PermissionController` | 11 | `user.*` (7) | +| `admin-workshops` | `WorkshopController` | 4 | `workshop.list`, `workshop.create` | +| `orders` | `ApiMobileController::setOrder` | 2 | `order.list`, `order.list.sts.submit` | + +**D1's requirement — "no operation left without an equivalent permission" — is +met:** every one of the 163 rid-guarded routes names a rid, and 66 of the 68 +resolve to a key the permission screen can grant. + +### The 2 exceptions — PERM-02 + +Two enforced rids are **absent from the permission tree**, so a route checks them +but no operator can grant them from the UI: + +| rid | Enforced by | Effect | +|---|---|---| +| `newsletters` | `admin-misc` (subscriber list) | only a role that already holds it (from the seed) can read the newsletter list | +| `persons.list.sellers.view` | `admin-customers` (`GET /reagent/view/:id`, the ➕ target-only agent-detail screen) | the new screen cannot be granted to a new role | + +This is **inherited from the reference, not introduced here** — the header of +`permissionConst.ts` already recorded four such rids. Two of the four +(`buy.order.list`, `persons.create.customer.tak`) now have no route at all, so +they are dead on both sides and need no action. + +Logged as **PERM-02**. Additive fix (add the two keys to the tree); it touches +the permission vocabulary, so it waits for the owner like PERM-01. --- @@ -225,7 +274,12 @@ Contrary to the Phase 0 expectation, this area is in **good** shape. | Over-limit outcome | order still created, `sts = 0` (معلق) | `OrderStatus.SUSPENDED` (`orders.service.ts:92`) | ✅ match | | Debt recognition point | `sts > 4`, first true at `sts = 5` | `DELIVERED_TO_AGENT`, documented at `admin-orders.service.ts:14` and mapped `5 → DELIVERED_TO_AGENT` at `:359` | ✅ match | | Removing that status | legacy allowed it, losing the posted debt | deliberately refused (`admin-orders.service.ts:382-390`) | ➕ stricter — a data-loss guard, keep (D4) | -| Suspended-order SMS | `ApiMobileController.php:739` | not yet sent | ❌ — lands with the Phase 1 SMS templates | +| Suspended-order SMS | `ApiMobileController.php:754` | `orders.service.ts` `notifyOrderPlaced` | ✅ fixed 2026-10-12 | +| Order-placed SMS to the customer | `:757` | same | ✅ fixed 2026-10-12 | +| Order-placed SMS to the admins | `:739-743` (4 hardcoded numbers) | same, via `ORDER_ADMIN_MOBILES` | ✅ fixed 2026-10-12 | +| Order-placed SMS to the sales expert | `:762-764` | `notifySalesAgent` | ✅ fixed 2026-10-12 | +| Order-**edited** SMS to the admins | `:734-737` | none | «عمداً تکرار نشد — SMS-03» — no target path lets a customer edit a placed order | +| `Notify::store` push beside each SMS | `:755,758,765` | none | ⏸️ PUSH-01, deferred to the PWA port | The one **money hole** here is already logged and scheduled: the server accepts client-supplied `wagePercent` / `discountPercent` / `flatRialCharge` @@ -424,4 +478,6 @@ layout as the reference. The target reads from `OrderItem` + `LedgerEntry` | RPT-01 | Medium | The reference's 32-column Excel export (`ReportController::getOrderItemWithDetails`) had no target equivalent. | ✅ fixed — `admin-report` module, `GET admin/report/order-items` | | SMS-01 | Low | SMS on document confirm/unconfirm was not wired in `toggleConfirm`. | ✅ fixed — `admin-documents.service.ts:notifyDocumentConfirm` | | SMS-02 | Low | `%name` substitution in batch SMS text was missing. | ✅ fixed — `admin-misc.service.ts:sendSms` + `customerNamesByMobile` | +| SMS-03 | Medium | **None of the four order-placement SMS messages was wired** — `OrdersService` did not even inject `SmsService`. The matrix had logged only the suspended-order one. | ✅ fixed 2026-10-12 — `notifyOrderPlaced` + `notifySalesAgent`, 6 tests. The «ویرایش شد» variant is deliberately not ported (no such path). | +| PERM-02 | Medium | `newsletters` and `persons.list.sellers.view` are enforced by routes but absent from the permission tree, so they cannot be granted from the UI. Inherited from the reference. | awaiting owner (additive) | | PUSH-01 | Low | Firebase push notifications not implemented — deferred to PWA port phase. | ⏸️ deferred | diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 5a0b545..f8e1127 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -769,3 +769,63 @@ REL-01/REL-02 in ISSUES. build 5/5 · jest 45/45 · admin smoke 43/43 · apps smoke 43/43 · B2B scan 34 ok / 0 broken · B2B writes 25/25 · auth-smoke 32/32 · sales-expert 33/33 · name trigger 9/9 · typecheck clean on all four apps. + +--- + +## 2026-10-12 — Phase 1 close-out — order SMS (SMS-03) and the §2 rid matrix + +### 1. Order-placement SMS — SMS-03 + +- **What:** `OrdersService` now injects `SmsService` and, after the order + transaction commits, texts three audiences: the admins listed in + `ORDER_ADMIN_MOBILES`, the customer (different text when the order was + suspended for exceeding the gram ceiling), and the customer's referring sales + expert. Brand «منتزر» replaces «هنزا گلد»; every other character of the four + texts is the reference's. +- **Why:** the matrix had logged only the suspended-order message as missing. On + reading the reference, **all four** messages were absent and `OrdersService` + did not inject `SmsService` at all — a customer placing an order was told + nothing, and neither was anyone else. +- **Reference:** `henza_server/app/Http/Controllers/Api/ApiMobileController.php:733-766` +- **Target:** `apps/api/src/modules/orders/orders.service.ts` + (`notifyOrderPlaced`, `notifySalesAgent`), `orders.module.ts`, `.env.example` +- **Matrix row:** `01-ADMIN-MATRIX.md` §4, SMS-03 +- **Deliberate non-replication:** the «در سیستم ویرایش شد» variant (`:734-737`) + is **not** ported — no target path lets a customer edit a placed order, so the + branch could never fire. Annotated «عمداً تکرار نشد — SMS-03» per D4. +- **Decisions carried over:** admin numbers come from an env var rather than the + reference's four hardcoded personal numbers, matching the existing + `PROFILE_ADMIN_MOBILES` pattern; SMS is fire-and-forget per D20, so a dead + gateway can never fail an order; the messages are sent **after** the + transaction commits, as the reference did, so a rolled-back order texts nobody. + `Notify::store` (Firebase push) beside each message stays deferred as PUSH-01. +- **Tests:** 6 new cases in `orders.service.spec.ts` (customer placed/suspended + text, admin fan-out, no-admins-configured, sales expert, expert without a + mobile). Full api suite **283/283**, typecheck clean. +- **Found while testing:** the pre-existing "sales expert credit" suite mocked no + `adminUser`, so the new agent lookup hit its catch and logged an error while + the test still passed green. Mock completed — a passing test now means the + path actually ran. + +### 2. §2 of the admin matrix — the D1 rid matrix + +- **What:** filled in the section that had stood as *(pending)* since the phase + began, mechanically from the target's `@RequirePermission` decorators and + `permissionConst.ts`: 163 rid-guarded routes, 68 distinct rids, 176 tree keys, + mapped per module onto the reference controller each one ports. +- **Why:** D1 required proof that no admin operation is left without an + equivalent permission. It is the last unfinished item of Phase 1. +- **Result:** 66 of the 68 enforced rids are grantable from the permission + screen. The 2 that are not — `newsletters` and `persons.list.sellers.view` — + are logged as **PERM-02**; both are inherited from the reference, whose own + `permissionConst.ts` header had already recorded four such rids (the other two + now have no route on either side). +- **Target:** `docs/parity/01-ADMIN-MATRIX.md` §2 +- **Note on method:** the first extraction pass read the rid that followed each + HTTP decorator and produced an off-by-one map (it claimed + `reconcile → wallet.list`). Caught by spot-checking against this CHANGELOG's + own stage-1 entry, which records `reconcile → wallet.charge`. The corrected + pass was re-verified against five independently documented mappings before any + table was written. + +_No data was deleted in this entry._ diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index 2fee778..38e46eb 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -127,6 +127,13 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | PRICE-02 | Info | REF `app/Models/ProductVariant.php:163` vs TGT `admin-products.service.ts:206-208` | `rial_pay` (a flat rial charge per variant) appears only in the **superseded, commented-out** formula, not the live one at `:183-190`. The target returns null rather than faking it, which matches the live reference. Recorded so nobody "restores" it from the dead code. | Read both. | ✅ No action — matches. | | PERM-01 | Medium | TGT `prisma/seed.ts`; `apps/admin-panel/src/views/user/permissionConst.ts` | `dashboard.omde` and `dashboard.tak` are seeded into the `permission` table but are **absent from the permission UI tree**, so they can be held by a row yet never seen, granted or revoked from the admin screen — and nothing reads them any more (the protected Dashboard uses `dashboard.view`). The DB holds **178** distinct rids against the UI's **174**. | `SELECT rid FROM permission GROUP BY rid` → 178, including all three dashboard rids. | ✅ **FIXED 2026-10-08** (owner approved the deletion). Migration `20261008160000_dashboard_permission_merge` gives `dashboard.view` to every type holding `dashboard` / `dashboard.omde` / `dashboard.tak`, then deletes those rows. **Also found:** the import dropped these three as unknown rids, so legacy types 102, 104, 105, 108 and 135 lost the dashboard — `people.ts` RENAMED_RIDS now maps them to `dashboard.view`. The other 45 legacy rids the target lacks are tree parents or rids no legacy code reads. | +## Found while closing Phase 1 (2026-10-12) + +| ID | Severity | Where | Description | Evidence | Status | +|---|---|---|---|---|---| +| SMS-03 | Medium | TGT `apps/api/src/modules/orders/orders.service.ts` | **No order-placement SMS at all.** The reference texts four audiences when an order is placed (`ApiMobileController.php:733-766`): the admins, the customer — with a distinct text when the order was suspended over the gram ceiling — and the customer's referring sales expert. `OrdersService` did not inject `SmsService`, so a customer placing an order was told nothing. The matrix had logged only the suspended-order message, understating the gap. | Read both sides; `grep SmsService` showed six modules wired and `orders` absent. | ✅ **FIXED 2026-10-12** — `notifyOrderPlaced` + `notifySalesAgent`, sent after the transaction commits, fire-and-forget per D20, admin numbers from `ORDER_ADMIN_MOBILES`. 6 tests. The «ویرایش شد» variant is «عمداً تکرار نشد» — no target path edits a placed order. | +| PERM-02 | Medium | TGT `apps/admin-panel/src/views/user/permissionConst.ts` | Two rids are **enforced by routes but absent from the permission tree**, so an operator cannot grant them: `newsletters` (newsletter subscriber list) and `persons.list.sellers.view` (the ➕ target-only agent-detail screen). Only a role that already holds them from the seed can use those routes. Inherited from the reference — that file's own header recorded four such rids; the other two (`buy.order.list`, `persons.create.customer.tak`) now have no route on either side and are dead. | Mechanical comparison of 68 enforced rids against 176 tree keys, `01-ADMIN-MATRIX.md` §2. | **awaiting owner** — the fix is additive (add the two keys to the tree) but it changes the permission vocabulary, so it waits like PERM-01 did. | + ## Delivery-mode items (D19/D20, 2026-09-22) | ID | Severity | Where | Description | Evidence | Suggested fix | diff --git a/docs/parity/PROGRESS.md b/docs/parity/PROGRESS.md index f5ec9a4..c25fa85 100644 --- a/docs/parity/PROGRESS.md +++ b/docs/parity/PROGRESS.md @@ -19,12 +19,36 @@ The "Current state" table below is the parity project's and was last updated 202 | Field | Value | |---|---| -| **Phase** | **Phase 1 — admin parity, matrix in progress** | -| **Branch** | `parity/phase-1-admin` (cut from `parity/security-hotfix`) | -| **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. | -| **Next step** | Phase 1(a) continued: `singleCustomer` diff-send, then the field-by-field audit of all Persons forms and grids | -| **Blocked on** | nothing | -| **Last updated** | 2026-09-22 | +| **Phase** | **Phase 1 — admin parity — COMPLETE, at the gate** | +| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) | +| **Status** | All in-scope sections closed: §2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ out of scope by D16. **283/283** jest tests, typecheck clean. | +| **Next step** | **GATE question asked 2026-10-12 — waiting on the owner** before Phase 2 (apps parity). | +| **Blocked on** | the gate answer. Two additive items await an owner ruling: **PERM-02** and the open rows below. | +| **Last updated** | 2026-10-12 | + +### Phase 1 close-out (2026-10-12) + +The two items that were still genuinely unfinished: + +| Item | Result | +|---|---| +| §2 — the D1 rid matrix (stood as *(pending)* all phase) | ✅ filled: 163 rid-guarded routes, 68 distinct rids, 176 tree keys, mapped per module. 66/68 grantable from the UI; the 2 that are not are **PERM-02**. | +| Order-placement SMS | ✅ **SMS-03** — the matrix had logged one missing message; in fact all four were missing and `OrdersService` had no `SmsService`. Fixed with 6 tests. | + +**What is NOT done, by decision rather than by omission:** + +| | Why | +|---|---| +| §3 — product price display (PRICE-01) | ⏸️ retail storefront, out of scope by **D13/D16**. `variant-price.ts` + 11 tests kept but unused. | +| Firebase push beside each SMS (PUSH-01) | ⏸️ deferred to the PWA port. | +| «سفارش ویرایش شد» admin SMS | «عمداً تکرار نشد» (D4) — no target path edits a placed order. | +| PERM-02 (2 ungrantable rids) | additive, changes the permission vocabulary → awaits the owner, as PERM-01 did. | +| `02-APPS-MATRIX.md` | Phase 2's file; not created yet. | + +⚠️ **Not verified in a browser.** Every ✅ above rests on reading both sides, +unit tests and smoke scripts. The prior port's note still stands: no admin view +has been visually checked in a browser. A full click-through is the honest +remaining risk on Phase 1. ### Security hotfix — final result @@ -92,8 +116,8 @@ All user rulings live there (D0–D17). Highlights that change how work is done: - [x] P0.7 — Urgent traces delivered: `percent`, `@Public` list, login leak - [x] P0.8 — Skill updated with rules C0 (field-by-field) and E0 (round-trip test) - [x] **P1.0 — approved and DONE:** 5 ledger routes protected, `percent` column added, D0 migration applied, SMS provider implemented -- [ ] Phase 1 — Admin parity (branch `parity/phase-1-admin`, `01-ADMIN-MATRIX.md`) ← HERE -- [ ] **GATE:** "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?" +- [x] Phase 1 — Admin parity (`01-ADMIN-MATRIX.md`) — all in-scope sections closed 2026-10-12 +- [ ] **GATE** ← HERE — asked 2026-10-12: "Admin parity is complete and tested. Shall we move on to the apps (user-app & user-app-v2)?" - [ ] Phase 2 — Apps parity (`02-APPS-MATRIX.md`, both user apps) - [ ] Phase 3 — Work through `ISSUES.md` (only when user asks)