From d91dcf197b23614593c5470ffc13067d4730495a Mon Sep 17 00:00:00 2001 From: "s.esmaeili" Date: Thu, 8 Oct 2026 16:45:41 +0330 Subject: [PATCH] feat(admin): birth date on the customer forms; permission saves limited to known rids (PERS-03, SEC-07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PERS-03: «تاریخ تولد» (Jalali DateInput) on the wholesale and retail forms, as legacy. The server accepts birth_date or birth_day, refuses impossible or future dates, clears on '', and returns both names. - SEC-07: setRoles stores only rids in ALL_RIDS or their tree parents. Anything else is dropped with a warning log, so a tick on a legacy-only tree leaf never fails the save. Co-Authored-By: Claude Opus 5.5 --- .../src/views/apps/customer/bulkCustomer.vue | 18 +++++++++-- .../views/apps/customer/singleCustomer.vue | 8 +++++ .../admin-customers.service.ts | 24 ++++++++++++++ .../admin-users/admin-users.roles.spec.ts | 31 +++++++++++++++++++ .../admin-users/admin-users.service.ts | 26 ++++++++++++++-- docs/parity/ISSUES.md | 4 +-- 6 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/admin-users/admin-users.roles.spec.ts diff --git a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue index dbe7124..e327540 100644 --- a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue @@ -10,8 +10,9 @@ render this component directly as a route-based fallback, but OmdeList.vue now opens it as a modal via open/id props. - Dropped: the MyUpload avatar widget and the Jalali DateInput, which belong to - fields the redesigned Customer schema has no column for. Flagged in + «تاریخ تولد» (legacy's Jalali DateInput, birth_date) is back since the + Customer schema has birthDate (PERS-03). Dropped: the MyUpload avatar widget, + which belongs to a field the redesigned Customer schema has no column for. Flagged in docs/ADMIN-PORT-PLAN.md rather than faked. The نوع کاربری selector is live again now that Customer.type exists; its four @@ -79,6 +80,11 @@ + @@ -182,6 +188,11 @@ + @@ -250,6 +261,7 @@ import BaseFormModal from '../../../components/common/BaseFormModal.vue'; import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue'; import api from '../../../api/client'; import CityTreeSelect from '../../../components/common/CityTreeSelect.vue'; +import DateInput from '../../../components/input/DateInput.vue'; const props = defineProps<{ open?: boolean; @@ -294,6 +306,7 @@ const form = reactive>({ pasazh_name: '', city_id: undefined, mcode: '', + birth_date: '', address: '', // legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the // backend falls back to when the field is absent. @@ -465,6 +478,7 @@ onMounted(async () => { store_name: data.store_name ?? '', pasazh_name: data.pasazh_name ?? '', mcode: data.mcode ?? '', + birth_date: data.birth_date ?? '', phone: data.phone ?? '', type: data.type ?? undefined, // Same reason as the five above: omitted here, they would be sent back diff --git a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue index c298e60..af2ad8e 100644 --- a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue @@ -34,6 +34,11 @@ + ثبت اطلاعات @@ -48,6 +53,7 @@ import { useRoute, useRouter } from 'vue-router'; import { message } from 'ant-design-vue'; import api from '../../../api/client'; import CityTreeSelect from '../../../components/common/CityTreeSelect.vue'; +import DateInput from '../../../components/input/DateInput.vue'; const route = useRoute(); const router = useRouter(); @@ -61,6 +67,7 @@ const form = reactive>({ lname: '', phone: '', city_id: undefined, + birth_day: '', }); const err = reactive>({}); const REQUIRED = 'لطفا مقدار را وارد کنید'; @@ -115,6 +122,7 @@ onMounted(async () => { lname: data.lname ?? '', phone: data.mobile ?? '', city_id: data.city_id ?? undefined, + birth_day: data.birth_day ?? '', }); } catch (e) { console.log(e); diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts index 3fc293d..0b370dc 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.service.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -69,6 +69,23 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch * `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them. * The new schema models one ordered enum, so these numbers are derived from it. */ +/** + * PERS-03: the birth date both customer forms collect (legacy bulkCustomer + * `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian + * 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the + * import stores legacy's. undefined = not sent (leave as is), '' = cleared. + */ +function birthDateOrNull(raw: unknown): Date | null | undefined { + if (raw === undefined) return undefined; + if (raw === null || String(raw).trim() === '') return null; + const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim()); + const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null; + if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) { + throw new BadRequestException('تاریخ تولد معتبر نیست'); + } + return d; +} + /** * A national code as the operator typed it, digits normalised; empty -> null. * The column is optional and unique (2026-10-07): an absent code is NULL, @@ -344,6 +361,9 @@ export class AdminCustomersService { zone: c.zone, pasazh_name: c.pasazhName, mcode: c.memberCode, + // Both legacy names: bulkCustomer reads birth_date, singleCustomer birth_day. + birth_date: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null, + birth_day: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null, }; } @@ -650,6 +670,7 @@ export class AdminCustomersService { nationalCode: null, passwordHash: OTP_ONLY_PASSWORD_HASH, cityId: (data.city_id as string) || null, + birthDate: birthDateOrNull(data.birth_day ?? data.birth_date) ?? null, kind: CustomerKind.RETAIL, }, }); @@ -726,6 +747,7 @@ export class AdminCustomersService { pasazhName: (data.pasazh_name as string) ?? null, zone: (data.zone as string) ?? null, memberCode: (data.mcode as string) ?? null, + birthDate: birthDateOrNull(data.birth_date ?? data.birth_day) ?? null, // legacy `image_id` from MyUpload's `ok` payload avatarFileId: (data.image_id as string) || null, // The three document photos the wholesale form collects. These were @@ -802,6 +824,8 @@ export class AdminCustomersService { if (data.pasazh_name !== undefined) patch.pasazhName = String(data.pasazh_name); if (data.zone !== undefined) patch.zone = String(data.zone); if (data.mcode !== undefined) patch.memberCode = String(data.mcode); + const birthDate = birthDateOrNull(data.birth_date !== undefined ? data.birth_date : data.birth_day); + if (birthDate !== undefined) patch.birthDate = birthDate; if (data.reagent_id !== undefined) { patch.referredByAgent = data.reagent_id ? { connect: { id: String(data.reagent_id) } } diff --git a/apps/api/src/modules/admin-users/admin-users.roles.spec.ts b/apps/api/src/modules/admin-users/admin-users.roles.spec.ts new file mode 100644 index 0000000..24c59b4 --- /dev/null +++ b/apps/api/src/modules/admin-users/admin-users.roles.spec.ts @@ -0,0 +1,31 @@ +import { AdminUsersService } from './admin-users.service'; + +/** SEC-07: a role's saved permissions are limited to rids the server knows. */ +describe('AdminUsersService.setRoles', () => { + let createMany: jest.Mock; + let service: AdminUsersService; + + beforeEach(() => { + createMany = jest.fn(); + const prisma = { + userType: { findUnique: jest.fn(async () => ({ id: 90, permissions: [] })) }, + permission: { deleteMany: jest.fn(), createMany }, + $transaction: jest.fn(async () => []), + }; + service = new AdminUsersService(prisma as never, {} as never, {} as never); + }); + + it('keeps catalogue rids and their tree parents, drops anything else', async () => { + await service.setRoles(90, [ + 'order.list', + 'order', // tree parent of order.list + 'persons', // tree parent of persons.* + 'notify.create.single.customer', // legacy tree leaf no route checks + 'made.up.permission', + 'order.list', // duplicate + '', + ]); + const rows = createMany.mock.calls[0][0].data.map((d: { rid: string }) => d.rid); + expect(rows.sort()).toEqual(['order', 'order.list', 'persons']); + }); +}); diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts index b442e1e..2bd5d41 100644 --- a/apps/api/src/modules/admin-users/admin-users.service.ts +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AdminRole, Prisma } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../prisma/prisma.service'; @@ -6,6 +6,18 @@ import { TableService } from '../admin-table/table.service'; import { AdminSessionService } from '../sessions/admin-session.service'; import type { TableQueryDto } from '../admin-table/table-query.dto'; import { faToEn } from '../customer-auth/digits'; +import { ALL_RIDS } from '../auth/acl.constants'; + +/** + * SEC-07: the rids a role may hold — every rid in the catalogue and each of + * their tree parents ("persons", "order.omde", …), which the permission tree + * posts when a whole branch is ticked. + */ +const STORABLE_RIDS: ReadonlySet = new Set( + (ALL_RIDS as readonly string[]).flatMap((rid) => + rid.split('.').map((_, i, parts) => parts.slice(0, i + 1).join('.')), + ), +); /** * Admin users and the permission matrix — legacy views/user/*, served by @@ -22,6 +34,8 @@ import { faToEn } from '../customer-auth/digits'; */ @Injectable() export class AdminUsersService { + private readonly logger = new Logger(AdminUsersService.name); + constructor( private readonly prisma: PrismaService, private readonly table: TableService, @@ -449,7 +463,15 @@ export class AdminUsersService { const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } }); if (!type) throw new NotFoundException('سطح دسترسی یافت نشد'); - const clean = [...new Set((rids ?? []).map((r) => String(r)).filter(Boolean))]; + const posted = [...new Set((rids ?? []).map((r) => String(r).trim()).filter(Boolean))]; + // SEC-07: only rids the server knows are stored. The rest grant nothing — + // most are legacy tree leaves no route checks (e.g. notify.create.single.*) + // — so they are dropped rather than failing the whole save. + const clean = posted.filter((r) => STORABLE_RIDS.has(r)); + const dropped = posted.filter((r) => !STORABLE_RIDS.has(r)); + if (dropped.length) { + this.logger.warn(`setRoles(${userTypeId}): ignored ${dropped.length} unknown rid(s): ${dropped.slice(0, 20).join(', ')}`); + } await this.prisma.$transaction([ this.prisma.permission.deleteMany({ where: { userTypeId } }), diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index 5e13f9b..81a0a54 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -24,7 +24,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | SEC-04 | ~~High~~ **FIXED 2026-10-08** — production (`NODE_ENV=production`) refuses to boot without `JWT_SECRET`; CORS reads `CORS_ORIGINS` (exact origins, comma-separated), blank = allow-all with a boot warning in production. Verified: boot refused with a blank key in production only; a listed origin gets `Access-Control-Allow-Origin`, an unlisted one does not. | TGT `apps/api` (two files) | `JWT_SECRET` falls back to `'dev-only-insecure-secret'` and is absent from `.env.example`. `enableCors()` is wide open. | Reported by api inventory; see `_inv-tgt-api.md` §11. | Require the env var at boot; restrict CORS origins. | | SEC-05 | ~~High~~ **FIXED 2026-10-08** — worse than logged: the app runs the seed once on a fresh database at boot (`bootstrap.ts`), and the WHOLE seed was demo data (sample customers, products, orders, posted debt, and admin accounts with known passwords). Now only roles + permissions run in production; the rest needs `SEED_DEMO_DATA=true`. ⚠ Any deployment that already booted on an empty database has these demo rows and must be checked. | TGT `apps/api/prisma/seed.ts` | Seed creates a SUPER_ADMIN (`09128081392`) whose **password equals the phone number**, holding all 144 rids. Marked DEV-ONLY in comments but lives in the normal seed. | `docs/CHANGELOG-PORT-HISTORY.md` §4 documents this as intentional for dev. | Gate behind an explicit env flag so it can never run outside dev. | | SEC-06 | High | REF `henza_server` | Hardcoded secrets committed to the repo: SMS panel password, PayPing bearer token, Tahesab bearer + IP allowlist, wallet hash pepper (`"yhoenszeaf"`). | See `_inv-ref-server.md` §11.4. | Rotate all of them before any deployment that reuses these credentials. | -| SEC-07 | Medium | TGT `apps/api/src/modules/admin-customers/admin-customers.service.ts` | `setRoles` stores arbitrary strings as rids with no validation against `ALL_RIDS`. | api inventory §9. | Validate against the rid catalogue. | +| SEC-07 | Medium | TGT `apps/api/src/modules/admin-customers/admin-customers.service.ts` | `setRoles` stores arbitrary strings as rids with no validation against `ALL_RIDS`. | api inventory §9. | Validate against the rid catalogue. ✅ **FIXED 2026-10-08:** `setRoles` stores only rids in ALL_RIDS or their tree parents; anything else is dropped with a warning log, so a tick on a legacy-only tree leaf (e.g. notify.create.single.*, which no route checks) cannot fail the save. Test: admin-users.roles.spec.ts. | ## Customer / person module (found during the user-requested deep check) @@ -135,7 +135,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium` | **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. | | **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. | | **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | ✅ **FIXED 2026-10-08** (owner: as legacy). `City.parentId` (self relation, Restrict) replaces `province`; migration `20261008170000_city_tree` turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (`CityTreeSelect.vue`: top level with children not selectable); city form/grid have «والد». **Also restored:** legacy `_getP` — a city without its own percent takes its ancestor's (`getCityPercent`); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused. | -| **PERS-03** | Low — **column added 2026-10-07 (`Customer.birthDate`, 541 legacy dates imported); form field still missing** | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. | +| **PERS-03** | Low — **column added 2026-10-07 (`Customer.birthDate`, 541 legacy dates imported); form field still missing** | REF `bulkCustomer.vue:111`, `singleCustomer.vue:29` vs TGT | The reference collects a **Jalali birth date** on both the wholesale (`birth_date`) and retail (`birth_day`) customer forms via `DateInput`. The target's `Customer` model has no column for it and both forms omit the field. | `henza_admin/.../bulkCustomer.vue:111`; `schema.prisma` `model Customer` (no birth field) | Additive: add a nullable `birthDate` column and the picker, if the owner wants it. ✅ **FIXED 2026-10-08:** Jalali «تاریخ تولد» (DateInput) on the wholesale (`birth_date`) and retail (`birth_day`) forms; create/update accept either name, refuse impossible/future dates, '' clears; customer rows return both names. | | **PERF-01** | Low | TGT `admin-customers.service.ts` `listReagents()` | The per-agent gold-sold total runs **one `order.aggregate` per row** inside `Promise.all` — an N+1 on every page of the sales-expert grid. Harmless at the current agent count. | `listReagents()` `rows.map(async ...)` | Replace with a single `groupBy` over the referring agent. | ## Apps — found during the delivery smoke test (2026-09-23)