feat(admin): birth date on the customer forms; permission saves limited to known rids (PERS-03, SEC-07)
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7bb5c2e00f
commit
d91dcf197b
@@ -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 @@
|
||||
<a-form-item label="کد مشتری">
|
||||
<a-input v-model:value="form.mcode" />
|
||||
</a-form-item>
|
||||
<DateInput
|
||||
:model-value="form.birth_date || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
||||
/>
|
||||
<a-form-item label="کارشناس فروش">
|
||||
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
|
||||
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
|
||||
@@ -182,6 +188,11 @@
|
||||
<a-form-item label="کد مشتری">
|
||||
<a-input v-model:value="form.mcode" />
|
||||
</a-form-item>
|
||||
<DateInput
|
||||
:model-value="form.birth_date || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
||||
/>
|
||||
<a-form-item label="کارشناس فروش">
|
||||
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
|
||||
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
|
||||
@@ -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<Record<string, any>>({
|
||||
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
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
<a-form-item label="شهر">
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
|
||||
</a-form-item>
|
||||
<DateInput
|
||||
:model-value="form.birth_day || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_day = v ?? '')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
|
||||
@@ -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<Record<string, any>>({
|
||||
lname: '',
|
||||
phone: '',
|
||||
city_id: undefined,
|
||||
birth_day: '',
|
||||
});
|
||||
const err = reactive<Record<string, string>>({});
|
||||
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);
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = 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 } }),
|
||||
|
||||
Reference in New Issue
Block a user