feat(orders): send the order-placement SMS and close Phase 1
The reference texted three audiences on every placed order
(henza_server Api/ApiMobileController::setOrder:733-766). None of it was
wired here — OrdersService had no SmsService at all, so a customer could
place an order and nobody was told: not the customer, not the admins, not
the referring sales expert.
Now, after the transaction commits (so a rolled-back order texts nobody)
and without ever being awaited (D20 — a dead gateway must not fail an
order):
- every mobile in ORDER_ADMIN_MOBILES, with the customer's name and
the rounded gold weight;
- the customer, with distinct text for a normal and an over-limit
suspended order;
- the referring sales expert, looked up on AdminUser (the target's
referring expert is an admin, not a customer as in the reference).
The reference's four hardcoded personal numbers stay out of git, behind
the same env-var pattern as PROFILE_ADMIN_MOBILES.
The «در سیستم ویرایش شد» variant was deliberately not ported: the target
has no order-edit route, so it would be dead code — «عمداً تکرار نشد».
Also fills §2 of the admin matrix, the D1 deliverable that had been
(pending) since the phase opened: 163 rid-guarded routes, 68 distinct
rids, 176 grantable tree keys, and a per-module map to the reference
controllers. Only two enforced rids are ungrantable from the permission
screen (newsletters, persons.list.sellers.view) — filed as PERM-02,
awaiting an owner ruling because it changes the permission vocabulary.
Phase 1 (admin parity) is at its gate. api: 283/283 tests, 26 suites.
پنل: پیامک ثبت سفارش برای مشتری، ادمینها و کارشناس فروش فعال شد.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
759625d2a1
commit
83e5d2868b
@@ -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=
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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._
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+32
-8
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user