From 51db4f4a4ddc97e3632956ebfadb9ff246d784b2 Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 1 Sep 2026 13:14:58 +0700 Subject: [PATCH] Refactor privilege management to support hierarchical privilege keys - Updated privilege key structure to use a 3- or 4-part dotted hierarchy (e.g., `GROUP.PARENT.MODULE`). - Modified the `RequirePrivilege` decorator to accept multiple keys, allowing for OR logic in privilege checks. - Enhanced `PrivilegesGuard` to validate against multiple privilege keys, improving access control logic. - Created migration scripts to update existing privilege keys in the database to the new format. - Updated related services, controllers, and tests to accommodate the new privilege key structure and validation logic. --- .cursor/rules/privileges.mdc | 6 +- .../0016_privilege_key_hierarchy.sql | 51 ++++++++++++++ drizzle/migrations/meta/_journal.json | 14 ++++ .../decorators/require-privilege.decorator.ts | 17 +++-- src/common/guards/privileges.guard.spec.ts | 54 +++++++++++---- src/common/guards/privileges.guard.ts | 4 +- src/modules/auth/auth.controller.spec.ts | 4 +- src/modules/auth/dto/auth.dto.ts | 2 +- .../branches/branches-read.controller.ts | 2 +- .../customers/customers-read.controller.ts | 9 ++- .../customers/customers-write.controller.ts | 22 +++--- .../divisions/divisions-read.controller.ts | 2 +- .../employees/employees-read.controller.ts | 2 +- .../products/products-read.controller.ts | 2 +- .../field/cycles/cycles.service.spec.ts | 2 +- src/modules/field/cycles/cycles.service.ts | 10 +-- src/modules/field/plans/plans.service.spec.ts | 2 +- src/modules/field/plans/plans.service.ts | 10 +-- .../shared/field-privilege.guard.spec.ts | 23 ++++--- .../field/shared/field-privilege.guard.ts | 6 +- .../field/shared/field-purpose.spec.ts | 11 ++- src/modules/field/shared/field-purpose.ts | 29 ++++---- src/modules/privileges/dto/privilege.dto.ts | 4 +- .../privileges/privilege-action.spec.ts | 11 +-- .../privileges/privilege-key-code.spec.ts | 67 +++++++++++++++++++ src/modules/privileges/privilege-key-code.ts | 22 +++++- .../privileges/privileges-keys.controller.ts | 2 +- .../privileges/privileges-read.controller.ts | 4 +- .../privileges/privileges-write.controller.ts | 14 ++-- .../privileges/privileges.repository.ts | 33 +++++++++ .../privileges/privileges.service.spec.ts | 41 +++++++++++- src/modules/privileges/privileges.service.ts | 18 +++++ .../shared/configs/logistics-reports.ts | 4 +- .../reports/shared/configs/sales-reports.ts | 10 +-- .../packing-slips-read.controller.ts | 2 +- .../sales-invoices-read.controller.ts | 9 ++- .../sales-invoices-write.controller.ts | 16 ++--- .../sales-orders-read.controller.ts | 9 ++- .../sales-orders-write.controller.ts | 16 ++--- .../sales-payments-read.controller.ts | 9 ++- .../sales-payments-write.controller.ts | 16 ++--- .../sales-requests-read.controller.ts | 9 ++- .../sales-requests-write.controller.ts | 16 ++--- src/modules/users/users-read.controller.ts | 2 +- test/privileges.e2e-spec.ts | 10 +-- 45 files changed, 466 insertions(+), 162 deletions(-) create mode 100644 drizzle/migrations/0016_privilege_key_hierarchy.sql create mode 100644 src/modules/privileges/privilege-key-code.spec.ts diff --git a/.cursor/rules/privileges.mdc b/.cursor/rules/privileges.mdc index eacbe14..778c6d9 100644 --- a/.cursor/rules/privileges.mdc +++ b/.cursor/rules/privileges.mdc @@ -11,7 +11,7 @@ alwaysApply: false Every **non-public** controller handler on a primary (CRUD) resource MUST use: ```typescript -@RequirePrivilege('MODULE.RESOURCE', 'view' | 'create' | 'update' | 'delete' | 'import') +@RequirePrivilege('GROUP.PARENT.MODULE' | ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], 'view' | 'create' | 'update' | 'delete' | 'import') ``` Map HTTP verbs to actions: @@ -24,13 +24,13 @@ Map HTTP verbs to actions: | `DELETE /:id`, bulk-delete | `delete` | | `POST /import` | `import` | -Key codes use dotted uppercase module levels (`PRIVILEGES`, `SALES.INVOICE`). New modules add a `privilege_keys` seed row via migration — do not invent a parallel permission helper. +Key codes use 3- or 4-part dotted uppercase hierarchy: `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SALES.ACTIVITIES.INVOICE`, `MOBILE.SALES.PLAN`). Pass a string or string array to `@RequirePrivilege`; arrays use OR semantics. New modules add `privilege_keys` rows via migration — do not invent a parallel permission helper. Seed an Administrator privilege only via SQL/ops after the first user exists (`created_by` requires a user). Documented bootstrap: insert privilege + details, then `UPDATE users SET privilege_id = …`. Do not auto-grant on register. ## Guard behavior -`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and the matrix cell must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny. +`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the user’s assigned privilege must be **status `active`** and at least one matrix cell in the required key list must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny. Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims). diff --git a/drizzle/migrations/0016_privilege_key_hierarchy.sql b/drizzle/migrations/0016_privilege_key_hierarchy.sql new file mode 100644 index 0000000..b436a40 --- /dev/null +++ b/drizzle/migrations/0016_privilege_key_hierarchy.sql @@ -0,0 +1,51 @@ +-- Rename existing privilege_keys to Group.Parent.Module[.Submodule] hierarchy +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.PRIVILEGES', "label" = 'Privileges', "sort_order" = 101 WHERE "code" = 'PRIVILEGES'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.USERS', "label" = 'Users', "sort_order" = 102 WHERE "code" = 'USERS'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.DIVISION', "label" = 'Divisions', "sort_order" = 103 WHERE "code" = 'CONFIGURATION.DIVISION'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.BRANCH', "label" = 'Branches', "sort_order" = 104 WHERE "code" = 'CONFIGURATION.BRANCH'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.CUSTOMER', "label" = 'Customers', "sort_order" = 105 WHERE "code" = 'CONFIGURATION.CUSTOMER'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.PRODUCT', "label" = 'Products', "sort_order" = 106 WHERE "code" = 'CONFIGURATION.PRODUCT'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.SETTING', "label" = 'Company settings', "sort_order" = 107 WHERE "code" = 'CONFIGURATION.SETTING'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.EMPLOYEE', "label" = 'Employees', "sort_order" = 111 WHERE "code" = 'CONFIGURATION.EMPLOYEE'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.CYCLE', "label" = 'Sales cycles', "sort_order" = 112 WHERE "code" = 'SALES.CYCLE'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.REQUEST', "label" = 'Sales requests', "sort_order" = 113 WHERE "code" = 'SALES.REQUEST'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.ORDER', "label" = 'Sales orders', "sort_order" = 114 WHERE "code" = 'SALES.ORDER'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.INVOICE', "label" = 'Sales invoices', "sort_order" = 115 WHERE "code" = 'SALES.INVOICE'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PAYMENT', "label" = 'Sales payments', "sort_order" = 116 WHERE "code" = 'SALES.PAYMENT'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PLAN', "label" = 'Sales plans', "sort_order" = 117 WHERE "code" = 'SALES.PLAN'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.REPORT', "label" = 'Sales reports', "sort_order" = 118 WHERE "code" = 'SALES.REPORT'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP', "label" = 'Packing slips', "sort_order" = 121 WHERE "code" = 'SALES.PACKING_SLIP'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.DATA.CYCLE', "label" = 'Logistics cycles', "sort_order" = 122 WHERE "code" = 'LOGISTICS.CYCLE'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', "label" = 'Logistics plans', "sort_order" = 123 WHERE "code" = 'LOGISTICS.PLAN'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.REPORT', "label" = 'Logistics reports', "sort_order" = 124 WHERE "code" = 'LOGISTICS.REPORT'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.PLAN.ATTENDANCE', "label" = 'Branch attendance', "sort_order" = 201 WHERE "code" = 'FIELD.ATTENDANCE'; +--> statement-breakpoint +UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.VISIT', "label" = 'Customer visits', "sort_order" = 202 WHERE "code" = 'FIELD.VISIT'; +--> statement-breakpoint +INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES + ('MOBILE.SALES.PLAN', 'Sales plans (mobile)', 203), + ('MOBILE.SALES.REQUEST', 'Sales requests (mobile)', 204), + ('MOBILE.SALES.ORDER', 'Sales orders (mobile)', 205), + ('MOBILE.SALES.INVOICE', 'Sales invoices (mobile)', 206), + ('MOBILE.SALES.PAYMENT', 'Sales payments (mobile)', 207), + ('MOBILE.SALES.CUSTOMER', 'Customers (mobile)', 208), + ('MOBILE.LOGISTICS.PLAN', 'Logistics plans (mobile)', 209); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 5793e43..0c5fc8e 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1787562000000, "tag": "0014_sales_invoice_location", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787563000000, + "tag": "0015_field_check_in", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787564000000, + "tag": "0016_privilege_key_hierarchy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/common/decorators/require-privilege.decorator.ts b/src/common/decorators/require-privilege.decorator.ts index a93dca5..41cb270 100644 --- a/src/common/decorators/require-privilege.decorator.ts +++ b/src/common/decorators/require-privilege.decorator.ts @@ -4,13 +4,22 @@ import type { PrivilegeAction } from '../../modules/privileges/privilege-action' export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege'; export type RequirePrivilegeMeta = { - readonly key: string; + readonly keys: readonly string[]; readonly action: PrivilegeAction; }; -/** Marks a handler as requiring a privilege matrix cell to be true. */ -export const RequirePrivilege = (key: string, action: PrivilegeAction) => +function normalizePrivilegeKeys( + keys: string | readonly string[], +): readonly string[] { + return typeof keys === 'string' ? [keys] : keys; +} + +/** Marks a handler as requiring one or more privilege matrix cells (OR). */ +export const RequirePrivilege = ( + keys: string | readonly string[], + action: PrivilegeAction, +) => SetMetadata(REQUIRE_PRIVILEGE_KEY, { - key, + keys: normalizePrivilegeKeys(keys), action, } satisfies RequirePrivilegeMeta); diff --git a/src/common/guards/privileges.guard.spec.ts b/src/common/guards/privileges.guard.spec.ts index 1374261..b195a78 100644 --- a/src/common/guards/privileges.guard.spec.ts +++ b/src/common/guards/privileges.guard.spec.ts @@ -12,14 +12,14 @@ import { import { PrivilegesGuard } from './privileges.guard'; describe('PrivilegesGuard', () => { - const checkPermission = jest.fn(); + const checkAnyPermission = jest.fn(); const getAllAndOverride = jest.fn(); const reflector = { getAllAndOverride, } as unknown as Reflector; const guard = new PrivilegesGuard(reflector, { - checkPermission, + checkAnyPermission, } as never); const user: AuthUser = { @@ -46,26 +46,48 @@ describe('PrivilegesGuard', () => { it('allows when no RequirePrivilege metadata', async () => { getAllAndOverride.mockReturnValue(undefined); await expect(guard.canActivate(createContext(user))).resolves.toBe(true); - expect(checkPermission).not.toHaveBeenCalled(); + expect(checkAnyPermission).not.toHaveBeenCalled(); }); - it('allows when permission value is true', async () => { - const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' }; + it('allows when permission value is true for a single key', async () => { + const meta: RequirePrivilegeMeta = { + keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'], + action: 'view', + }; getAllAndOverride.mockReturnValue(meta); - checkPermission.mockResolvedValue(true); + checkAnyPermission.mockResolvedValue(true); await expect(guard.canActivate(createContext(user))).resolves.toBe(true); - expect(checkPermission).toHaveBeenCalledWith( + expect(checkAnyPermission).toHaveBeenCalledWith( 'user-1', - 'PRIVILEGES', + ['ADMIN.SETTINGS.USER.PRIVILEGES'], + 'view', + ); + }); + + it('allows when any key in the list is granted', async () => { + const meta: RequirePrivilegeMeta = { + keys: ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], + action: 'view', + }; + getAllAndOverride.mockReturnValue(meta); + checkAnyPermission.mockResolvedValue(true); + + await expect(guard.canActivate(createContext(user))).resolves.toBe(true); + expect(checkAnyPermission).toHaveBeenCalledWith( + 'user-1', + ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], 'view', ); }); it('forbids when permission is false or missing', async () => { - const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' }; + const meta: RequirePrivilegeMeta = { + keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'], + action: 'delete', + }; getAllAndOverride.mockReturnValue(meta); - checkPermission.mockResolvedValue(false); + checkAnyPermission.mockResolvedValue(false); await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf( ForbiddenException, @@ -73,17 +95,23 @@ describe('PrivilegesGuard', () => { }); it('skips privilege lookup when user is superadmin', async () => { - const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' }; + const meta: RequirePrivilegeMeta = { + keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'], + action: 'delete', + }; getAllAndOverride.mockReturnValue(meta); await expect( guard.canActivate(createContext({ ...user, isSuperadmin: true })), ).resolves.toBe(true); - expect(checkPermission).not.toHaveBeenCalled(); + expect(checkAnyPermission).not.toHaveBeenCalled(); }); it('unauthorized when metadata present but no user', async () => { - const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' }; + const meta: RequirePrivilegeMeta = { + keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'], + action: 'view', + }; getAllAndOverride.mockReturnValue(meta); await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf( diff --git a/src/common/guards/privileges.guard.ts b/src/common/guards/privileges.guard.ts index cdaf86d..57f52f5 100644 --- a/src/common/guards/privileges.guard.ts +++ b/src/common/guards/privileges.guard.ts @@ -39,9 +39,9 @@ export class PrivilegesGuard implements CanActivate { return true; } - const allowed = await this.privilegesService.checkPermission( + const allowed = await this.privilegesService.checkAnyPermission( user.id, - required.key, + required.keys, required.action, ); if (!allowed) { diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts index f2397f8..96bd9b6 100644 --- a/src/modules/auth/auth.controller.spec.ts +++ b/src/modules/auth/auth.controller.spec.ts @@ -118,7 +118,7 @@ describe('AuthController', () => { status: 'active', }); privilegesService.getPermissionsMap.mockResolvedValue({ - PRIVILEGES: { + 'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: true, update: true, @@ -137,7 +137,7 @@ describe('AuthController', () => { ).resolves.toMatchObject({ privilege: { id: 'priv-1', code: 'ADMIN' }, permissions: { - PRIVILEGES: expect.objectContaining({ view: true }), + 'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ view: true }), }, }); }); diff --git a/src/modules/auth/dto/auth.dto.ts b/src/modules/auth/dto/auth.dto.ts index fd71264..b998b8d 100644 --- a/src/modules/auth/dto/auth.dto.ts +++ b/src/modules/auth/dto/auth.dto.ts @@ -123,7 +123,7 @@ export class MeResponseDto { @ApiProperty({ description: 'Permission matrix keyed by privilege key code', example: { - PRIVILEGES: { + 'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: false, update: false, diff --git a/src/modules/configuration/branches/branches-read.controller.ts b/src/modules/configuration/branches/branches-read.controller.ts index 66610e3..cbd59f5 100644 --- a/src/modules/configuration/branches/branches-read.controller.ts +++ b/src/modules/configuration/branches/branches-read.controller.ts @@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto'; import { BranchesService } from './branches.service'; -export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH'; +export const BRANCH_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.BRANCH'; @ApiTags('branches') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/src/modules/configuration/customers/customers-read.controller.ts b/src/modules/configuration/customers/customers-read.controller.ts index 59442e6..9cf6ff8 100644 --- a/src/modules/configuration/customers/customers-read.controller.ts +++ b/src/modules/configuration/customers/customers-read.controller.ts @@ -18,7 +18,10 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto'; import { CustomersService } from './customers.service'; -export const CUSTOMER_PRIVILEGE_KEY = 'CONFIGURATION.CUSTOMER'; +export const CUSTOMER_PRIVILEGE_KEYS = [ + 'ADMIN.SETTINGS.DATA.CUSTOMER', + 'MOBILE.SALES.CUSTOMER', +] as const; @ApiTags('customers') @ApiBearerAuth(BEARER_AUTH_NAME) @@ -28,7 +31,7 @@ export class CustomersReadController { @Get() @Pagination() - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'List customers' }) @ApiOkResponse({ schema: { @@ -50,7 +53,7 @@ export class CustomersReadController { } @Get(':id') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'Get customer detail' }) @ApiOkResponse({ type: CustomerDto }) @ApiNotFoundResponse() diff --git a/src/modules/configuration/customers/customers-write.controller.ts b/src/modules/configuration/customers/customers-write.controller.ts index e29cf4e..833cd1d 100644 --- a/src/modules/configuration/customers/customers-write.controller.ts +++ b/src/modules/configuration/customers/customers-write.controller.ts @@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator'; import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { isAllowedCsvUpload } from './customer-fields'; -import { CUSTOMER_PRIVILEGE_KEY } from './customers-read.controller'; +import { CUSTOMER_PRIVILEGE_KEYS } from './customers-read.controller'; import { CustomersService } from './customers.service'; import { BulkIdsDto, @@ -49,7 +49,7 @@ export class CustomersWriteController { constructor(private readonly customersService: CustomersService) {} @Post('import') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -88,7 +88,7 @@ export class CustomersWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Bulk delete customers' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -101,7 +101,7 @@ export class CustomersWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Bulk update customer status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -116,7 +116,7 @@ export class CustomersWriteController { } @Post() - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create') @ApiOperation({ summary: 'Create customer' }) @ApiCreatedResponse({ type: CustomerDto }) @ApiUnauthorizedResponse() @@ -132,7 +132,7 @@ export class CustomersWriteController { } @Post(':id/contacts') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Add a customer contact' }) @ApiOkResponse({ type: CustomerDto }) @ApiNotFoundResponse() @@ -147,7 +147,7 @@ export class CustomersWriteController { } @Patch(':id/contacts/:contactId') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update a customer contact' }) @ApiOkResponse({ type: CustomerDto }) @ApiNotFoundResponse() @@ -167,7 +167,7 @@ export class CustomersWriteController { @Delete(':id/contacts/:contactId') @HttpCode(204) - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Delete a customer contact' }) @ApiNoContentResponse() @ApiNotFoundResponse() @@ -181,7 +181,7 @@ export class CustomersWriteController { } @Patch(':id/status') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update customer status' }) @ApiOkResponse({ type: CustomerDto }) @ApiNotFoundResponse() @@ -196,7 +196,7 @@ export class CustomersWriteController { } @Patch(':id') - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update customer (not status)' }) @ApiOkResponse({ type: CustomerDto }) @ApiNotFoundResponse() @@ -215,7 +215,7 @@ export class CustomersWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Delete customer' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/configuration/divisions/divisions-read.controller.ts b/src/modules/configuration/divisions/divisions-read.controller.ts index 2ef0a7e..8f102d1 100644 --- a/src/modules/configuration/divisions/divisions-read.controller.ts +++ b/src/modules/configuration/divisions/divisions-read.controller.ts @@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto'; import { DivisionsService } from './divisions.service'; -export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION'; +export const DIVISION_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.DIVISION'; @ApiTags('divisions') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/src/modules/configuration/employees/employees-read.controller.ts b/src/modules/configuration/employees/employees-read.controller.ts index 9d59026..0fe435d 100644 --- a/src/modules/configuration/employees/employees-read.controller.ts +++ b/src/modules/configuration/employees/employees-read.controller.ts @@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto'; import { EmployeesService } from './employees.service'; -export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE'; +export const EMPLOYEE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.EMPLOYEE'; @ApiTags('employees') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/src/modules/configuration/products/products-read.controller.ts b/src/modules/configuration/products/products-read.controller.ts index c6c559c..8df58cb 100644 --- a/src/modules/configuration/products/products-read.controller.ts +++ b/src/modules/configuration/products/products-read.controller.ts @@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { ProductDto, ListProductsQueryDto } from './dto/product.dto'; import { ProductsService } from './products.service'; -export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT'; +export const PRODUCT_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.PRODUCT'; @ApiTags('products') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/src/modules/field/cycles/cycles.service.spec.ts b/src/modules/field/cycles/cycles.service.spec.ts index 458cf88..7838242 100644 --- a/src/modules/field/cycles/cycles.service.spec.ts +++ b/src/modules/field/cycles/cycles.service.spec.ts @@ -34,7 +34,7 @@ describe('CyclesService', () => { const employeesService = { findById: jest.fn(), findByCode: jest.fn() }; const branchesService = { findById: jest.fn(), findByCode: jest.fn() }; const customersService = { findById: jest.fn(), findByCode: jest.fn() }; - const privilegesService = { checkPermission: jest.fn() }; + const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() }; const now = DateTime.fromUnixMs(1_700_000_000_000); const user: AuthUser = { diff --git a/src/modules/field/cycles/cycles.service.ts b/src/modules/field/cycles/cycles.service.ts index dc7d418..fd66c69 100644 --- a/src/modules/field/cycles/cycles.service.ts +++ b/src/modules/field/cycles/cycles.service.ts @@ -26,7 +26,7 @@ import { type WeekdaysInput, } from '../shared/field-fields'; import { - fieldPrivilegeKey, + fieldPrivilegeKeys, isFieldPurpose, WEEKDAY_NAMES, type FieldPurpose, @@ -485,9 +485,9 @@ export class CyclesService { } const allowed: FieldPurpose[] = []; for (const purpose of ['sales', 'logistics'] as const) { - const ok = await this.privilegesService.checkPermission( + const ok = await this.privilegesService.checkAnyPermission( user.id, - fieldPrivilegeKey('cycle', purpose), + fieldPrivilegeKeys('cycle', purpose), action, ); if (ok) { @@ -505,9 +505,9 @@ export class CyclesService { if (user.isSuperadmin) { return; } - const ok = await this.privilegesService.checkPermission( + const ok = await this.privilegesService.checkAnyPermission( user.id, - fieldPrivilegeKey('cycle', purpose), + fieldPrivilegeKeys('cycle', purpose), action, ); if (!ok) { diff --git a/src/modules/field/plans/plans.service.spec.ts b/src/modules/field/plans/plans.service.spec.ts index ded3489..c073761 100644 --- a/src/modules/field/plans/plans.service.spec.ts +++ b/src/modules/field/plans/plans.service.spec.ts @@ -72,7 +72,7 @@ describe('PlansService', () => { markDraftsProcessed: jest.fn(), }; const packingSlipsService = { findById: jest.fn() }; - const privilegesService = { checkPermission: jest.fn() }; + const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() }; const user: AuthUser = { id: 'user-1', diff --git a/src/modules/field/plans/plans.service.ts b/src/modules/field/plans/plans.service.ts index 48beb2d..e054c09 100644 --- a/src/modules/field/plans/plans.service.ts +++ b/src/modules/field/plans/plans.service.ts @@ -27,7 +27,7 @@ import { CyclesRepository } from '../cycles/cycles.repository'; import { CompanySettingsService } from '../settings/company-settings.service'; import { cycleNumberForDate, - fieldPrivilegeKey, + fieldPrivilegeKeys, isFieldPurpose, noCycleMessage, type FieldPurpose, @@ -615,9 +615,9 @@ export class PlansService { } const allowed: FieldPurpose[] = []; for (const purpose of ['sales', 'logistics'] as const) { - const ok = await this.privilegesService.checkPermission( + const ok = await this.privilegesService.checkAnyPermission( user.id, - fieldPrivilegeKey('plan', purpose), + fieldPrivilegeKeys('plan', purpose), action, ); if (ok) { @@ -635,9 +635,9 @@ export class PlansService { if (user.isSuperadmin) { return; } - const ok = await this.privilegesService.checkPermission( + const ok = await this.privilegesService.checkAnyPermission( user.id, - fieldPrivilegeKey('plan', purpose), + fieldPrivilegeKeys('plan', purpose), action, ); if (!ok) { diff --git a/src/modules/field/shared/field-privilege.guard.spec.ts b/src/modules/field/shared/field-privilege.guard.spec.ts index 2f5f507..c5e6a1c 100644 --- a/src/modules/field/shared/field-privilege.guard.spec.ts +++ b/src/modules/field/shared/field-privilege.guard.spec.ts @@ -10,14 +10,14 @@ import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator'; import { FieldPrivilegeGuard } from './field-privilege.guard'; describe('FieldPrivilegeGuard', () => { - const checkPermission = jest.fn(); + const checkAnyPermission = jest.fn(); const getAllAndOverride = jest.fn(); const reflector = { getAllAndOverride, } as unknown as Reflector; const guard = new FieldPrivilegeGuard(reflector, { - checkPermission, + checkAnyPermission, } as never); const user: AuthUser = { @@ -48,7 +48,7 @@ describe('FieldPrivilegeGuard', () => { it('allows when no field privilege metadata', async () => { getAllAndOverride.mockReturnValue(undefined); await expect(guard.canActivate(createContext(user))).resolves.toBe(true); - expect(checkPermission).not.toHaveBeenCalled(); + expect(checkAnyPermission).not.toHaveBeenCalled(); }); it('allows sales purpose when sales key is granted', async () => { @@ -57,8 +57,9 @@ describe('FieldPrivilegeGuard', () => { action: 'create', }; getAllAndOverride.mockReturnValue(meta); - checkPermission.mockImplementation((_id: string, key: string) => - Promise.resolve(key === 'SALES.CYCLE'), + checkAnyPermission.mockImplementation( + (_id: string, keys: readonly string[]) => + Promise.resolve(keys.includes('ADMIN.SALES.DATA.CYCLE')), ); await expect( @@ -72,8 +73,9 @@ describe('FieldPrivilegeGuard', () => { action: 'update', }; getAllAndOverride.mockReturnValue(meta); - checkPermission.mockImplementation((_id: string, key: string) => - Promise.resolve(key === 'SALES.PLAN'), + checkAnyPermission.mockImplementation( + (_id: string, keys: readonly string[]) => + Promise.resolve(keys.includes('MOBILE.SALES.PLAN')), ); await expect( @@ -87,8 +89,9 @@ describe('FieldPrivilegeGuard', () => { action: 'view', }; getAllAndOverride.mockReturnValue(meta); - checkPermission.mockImplementation((_id: string, key: string) => - Promise.resolve(key === 'LOGISTICS.CYCLE'), + checkAnyPermission.mockImplementation( + (_id: string, keys: readonly string[]) => + Promise.resolve(keys.includes('ADMIN.LOGISTICS.DATA.CYCLE')), ); await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe( @@ -106,7 +109,7 @@ describe('FieldPrivilegeGuard', () => { await expect( guard.canActivate(createContext({ ...user, isSuperadmin: true })), ).resolves.toBe(true); - expect(checkPermission).not.toHaveBeenCalled(); + expect(checkAnyPermission).not.toHaveBeenCalled(); }); it('unauthorized when metadata present but no user', async () => { diff --git a/src/modules/field/shared/field-privilege.guard.ts b/src/modules/field/shared/field-privilege.guard.ts index 61a9194..e4caddd 100644 --- a/src/modules/field/shared/field-privilege.guard.ts +++ b/src/modules/field/shared/field-privilege.guard.ts @@ -10,7 +10,7 @@ import type { AuthUser } from '../../../common/auth/auth-user'; import type { PrivilegeAction } from '../../privileges/privilege-action'; import { PrivilegesService } from '../../privileges/privileges.service'; import { - fieldPrivilegeKey, + fieldPrivilegeKeys, isFieldPurpose, type FieldPurpose, type FieldResource, @@ -78,9 +78,9 @@ export class FieldPrivilegeGuard implements CanActivate { const purposes: FieldPurpose[] = ['sales', 'logistics']; const matches: FieldPurpose[] = []; for (const purpose of purposes) { - const ok = await this.privilegesService.checkPermission( + const ok = await this.privilegesService.checkAnyPermission( userId, - fieldPrivilegeKey(resource, purpose), + fieldPrivilegeKeys(resource, purpose), action, ); if (ok) { diff --git a/src/modules/field/shared/field-purpose.spec.ts b/src/modules/field/shared/field-purpose.spec.ts index 84ca29b..bc33456 100644 --- a/src/modules/field/shared/field-purpose.spec.ts +++ b/src/modules/field/shared/field-purpose.spec.ts @@ -1,6 +1,6 @@ import { cycleNumberForDate, - fieldPrivilegeKey, + fieldPrivilegeKeys, isFieldPurpose, isWeekdayName, noCycleMessage, @@ -19,8 +19,13 @@ describe('field purpose helpers', () => { }); it('maps resource and purpose to privilege keys', () => { - expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE'); - expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN'); + expect(fieldPrivilegeKeys('cycle', 'sales')).toEqual([ + 'ADMIN.SALES.DATA.CYCLE', + ]); + expect(fieldPrivilegeKeys('plan', 'logistics')).toEqual([ + 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', + 'MOBILE.LOGISTICS.PLAN', + ]); }); it('returns purpose-specific missing-cycle messages', () => { diff --git a/src/modules/field/shared/field-purpose.ts b/src/modules/field/shared/field-purpose.ts index e584335..31c74b7 100644 --- a/src/modules/field/shared/field-purpose.ts +++ b/src/modules/field/shared/field-purpose.ts @@ -22,28 +22,31 @@ export function isWeekdayName(raw: string): raw is WeekdayName { return (WEEKDAY_NAMES as readonly string[]).includes(raw); } -export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE'; -export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN'; -export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE'; -export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN'; -export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING'; -export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'FIELD.ATTENDANCE'; -export const FIELD_VISIT_PRIVILEGE_KEY = 'FIELD.VISIT'; +export const ADMIN_SALES_CYCLE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.CYCLE'; +export const ADMIN_SALES_PLAN_PRIVILEGE_KEY = 'ADMIN.SALES.ACTIVITIES.PLAN'; +export const ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.DATA.CYCLE'; +export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY = + 'ADMIN.LOGISTICS.ACTIVITIES.PLAN'; +export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN'; +export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN'; +export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING'; +export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE'; +export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT'; export type FieldResource = 'cycle' | 'plan'; -export function fieldPrivilegeKey( +export function fieldPrivilegeKeys( resource: FieldResource, purpose: FieldPurpose, -): string { +): readonly string[] { if (resource === 'cycle') { return purpose === 'sales' - ? SALES_CYCLE_PRIVILEGE_KEY - : LOGISTICS_CYCLE_PRIVILEGE_KEY; + ? [ADMIN_SALES_CYCLE_PRIVILEGE_KEY] + : [ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY]; } return purpose === 'sales' - ? SALES_PLAN_PRIVILEGE_KEY - : LOGISTICS_PLAN_PRIVILEGE_KEY; + ? [ADMIN_SALES_PLAN_PRIVILEGE_KEY, MOBILE_SALES_PLAN_PRIVILEGE_KEY] + : [ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY, MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY]; } export function noCycleMessage(purpose: FieldPurpose): string { diff --git a/src/modules/privileges/dto/privilege.dto.ts b/src/modules/privileges/dto/privilege.dto.ts index 7c17a74..0555a08 100644 --- a/src/modules/privileges/dto/privilege.dto.ts +++ b/src/modules/privileges/dto/privilege.dto.ts @@ -146,7 +146,7 @@ export class PrivilegeDetailDto { @ApiProperty({ format: 'uuid' }) privilegeKeyId!: string; - @ApiProperty({ example: 'SALES.INVOICE' }) + @ApiProperty({ example: 'ADMIN.SALES.ACTIVITIES.INVOICE' }) keyCode!: string; @ApiProperty({ example: 'Sales Invoice' }) @@ -197,7 +197,7 @@ export class PrivilegeKeyDto { @ApiProperty({ format: 'uuid' }) id!: string; - @ApiProperty({ example: 'PRIVILEGES' }) + @ApiProperty({ example: 'ADMIN.SETTINGS.USER.PRIVILEGES' }) code!: string; @ApiProperty({ example: 'Privileges' }) diff --git a/src/modules/privileges/privilege-action.spec.ts b/src/modules/privileges/privilege-action.spec.ts index 06ad419..be44319 100644 --- a/src/modules/privileges/privilege-action.spec.ts +++ b/src/modules/privileges/privilege-action.spec.ts @@ -17,14 +17,15 @@ describe('privilege-action', () => { }); describe('privilege-key-code', () => { - it('accepts dotted uppercase module levels', () => { - expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true); - expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true); - expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true); - expect(assertPrivilegeKeyCode('USERS')).toBe('USERS'); + it('accepts 3- and 4-part dotted uppercase codes', () => { + expect(isValidPrivilegeKeyCode('ADMIN.SETTINGS.USER.PRIVILEGES')).toBe(true); + expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.INVOICE')).toBe(true); + expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true); + expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN'); }); it('rejects invalid codes', () => { + expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false); expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false); expect(isValidPrivilegeKeyCode('SALES.')).toBe(false); expect(isValidPrivilegeKeyCode('.SALES')).toBe(false); diff --git a/src/modules/privileges/privilege-key-code.spec.ts b/src/modules/privileges/privilege-key-code.spec.ts new file mode 100644 index 0000000..5d0e93c --- /dev/null +++ b/src/modules/privileges/privilege-key-code.spec.ts @@ -0,0 +1,67 @@ +import { + assertPrivilegeKeyCode, + isValidPrivilegeKeyCode, + parsePrivilegeKeyCode, +} from './privilege-key-code'; + +describe('privilege-key-code', () => { + describe('isValidPrivilegeKeyCode', () => { + it('accepts 3-part keys', () => { + expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true); + expect(isValidPrivilegeKeyCode('ADMIN.SALES.REPORT')).toBe(true); + }); + + it('accepts 4-part keys', () => { + expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toBe(true); + expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.PLAN')).toBe(true); + }); + + it('rejects fewer than 3 segments', () => { + expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false); + expect(isValidPrivilegeKeyCode('SALES.PLAN')).toBe(false); + }); + + it('rejects more than 4 segments', () => { + expect(isValidPrivilegeKeyCode('A.B.C.D.E')).toBe(false); + }); + + it('rejects invalid characters', () => { + expect(isValidPrivilegeKeyCode('mobile.sales.plan')).toBe(false); + expect(isValidPrivilegeKeyCode('MOBILE..PLAN')).toBe(false); + }); + }); + + describe('assertPrivilegeKeyCode', () => { + it('returns the code when valid', () => { + expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN'); + }); + + it('throws when invalid', () => { + expect(() => assertPrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError); + }); + }); + + describe('parsePrivilegeKeyCode', () => { + it('parses a 3-part key with null submodule', () => { + expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN')).toEqual({ + group: 'MOBILE', + parent: 'SALES', + module: 'PLAN', + submodule: null, + }); + }); + + it('parses a 4-part key', () => { + expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({ + group: 'MOBILE', + parent: 'SALES', + module: 'PLAN', + submodule: 'ATTENDANCE', + }); + }); + + it('throws for invalid codes', () => { + expect(() => parsePrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError); + }); + }); +}); diff --git a/src/modules/privileges/privilege-key-code.ts b/src/modules/privileges/privilege-key-code.ts index baed8ce..0e64d93 100644 --- a/src/modules/privileges/privilege-key-code.ts +++ b/src/modules/privileges/privilege-key-code.ts @@ -1,6 +1,13 @@ -/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */ +/** Dotted uppercase: Group.Parent.Module or Group.Parent.Module.Submodule */ export const PRIVILEGE_KEY_CODE_PATTERN = - /^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$/; + /^[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)?$/; + +export type ParsedPrivilegeKeyCode = { + readonly group: string; + readonly parent: string; + readonly module: string; + readonly submodule: string | null; +}; export function isValidPrivilegeKeyCode(code: string): boolean { return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code); @@ -12,3 +19,14 @@ export function assertPrivilegeKeyCode(code: string): string { } return code; } + +export function parsePrivilegeKeyCode(code: string): ParsedPrivilegeKeyCode { + assertPrivilegeKeyCode(code); + const parts = code.split('.'); + if (parts.length === 3) { + const [group, parent, module] = parts; + return { group, parent, module, submodule: null }; + } + const [group, parent, module, submodule] = parts; + return { group, parent, module, submodule }; +} diff --git a/src/modules/privileges/privileges-keys.controller.ts b/src/modules/privileges/privileges-keys.controller.ts index eada4a3..a54b1c7 100644 --- a/src/modules/privileges/privileges-keys.controller.ts +++ b/src/modules/privileges/privileges-keys.controller.ts @@ -28,7 +28,7 @@ export class PrivilegeKeysController { @Get() @Pagination() - @RequirePrivilege('PRIVILEGES', 'view') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view') @ApiOperation({ summary: 'List privilege keys catalog' }) @ApiOkResponse({ schema: { diff --git a/src/modules/privileges/privileges-read.controller.ts b/src/modules/privileges/privileges-read.controller.ts index ba69009..e250aa0 100644 --- a/src/modules/privileges/privileges-read.controller.ts +++ b/src/modules/privileges/privileges-read.controller.ts @@ -30,7 +30,7 @@ export class PrivilegesReadController { @Get() @Pagination() - @RequirePrivilege('PRIVILEGES', 'view') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view') @ApiOperation({ summary: 'List privileges' }) @ApiOkResponse({ schema: { @@ -52,7 +52,7 @@ export class PrivilegesReadController { } @Get(':id') - @RequirePrivilege('PRIVILEGES', 'view') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view') @ApiOperation({ summary: 'Get privilege detail with matrix' }) @ApiOkResponse({ type: PrivilegeDetailResponseDto }) @ApiNotFoundResponse() diff --git a/src/modules/privileges/privileges-write.controller.ts b/src/modules/privileges/privileges-write.controller.ts index ee83fc9..2fb726f 100644 --- a/src/modules/privileges/privileges-write.controller.ts +++ b/src/modules/privileges/privileges-write.controller.ts @@ -46,7 +46,7 @@ export class PrivilegesWriteController { constructor(private readonly privilegesService: PrivilegesService) {} @Post('import') - @RequirePrivilege('PRIVILEGES', 'import') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -90,7 +90,7 @@ export class PrivilegesWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege('PRIVILEGES', 'delete') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete') @ApiOperation({ summary: 'Bulk delete privileges' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -103,7 +103,7 @@ export class PrivilegesWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege('PRIVILEGES', 'update') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update') @ApiOperation({ summary: 'Bulk update privilege status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -118,7 +118,7 @@ export class PrivilegesWriteController { } @Post() - @RequirePrivilege('PRIVILEGES', 'create') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'create') @ApiOperation({ summary: 'Create privilege' }) @ApiCreatedResponse({ type: PrivilegeDetailResponseDto }) @ApiUnauthorizedResponse() @@ -137,7 +137,7 @@ export class PrivilegesWriteController { } @Patch(':id/status') - @RequirePrivilege('PRIVILEGES', 'update') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update') @ApiOperation({ summary: 'Update privilege status' }) @ApiOkResponse({ type: PrivilegeDto }) @ApiNotFoundResponse() @@ -152,7 +152,7 @@ export class PrivilegesWriteController { } @Patch(':id') - @RequirePrivilege('PRIVILEGES', 'update') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update') @ApiOperation({ summary: 'Update privilege (not status)' }) @ApiOkResponse({ type: PrivilegeDetailResponseDto }) @ApiNotFoundResponse() @@ -173,7 +173,7 @@ export class PrivilegesWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege('PRIVILEGES', 'delete') + @RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete') @ApiOperation({ summary: 'Delete privilege' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/privileges/privileges.repository.ts b/src/modules/privileges/privileges.repository.ts index 555acf0..c9ddc9e 100644 --- a/src/modules/privileges/privileges.repository.ts +++ b/src/modules/privileges/privileges.repository.ts @@ -389,6 +389,39 @@ export class PrivilegesRepository { return row?.value === true; } + async checkAnyPermission( + userId: string, + keyCodes: readonly string[], + action: PrivilegeAction, + ): Promise { + if (keyCodes.length === 0) { + return false; + } + const [row] = await this.db + .select({ value: privilegeDetails.value }) + .from(users) + .innerJoin(privileges, eq(users.privilegeId, privileges.id)) + .innerJoin( + privilegeDetails, + eq(privilegeDetails.privilegeId, privileges.id), + ) + .innerJoin( + privilegeKeys, + eq(privilegeDetails.privilegeKeyId, privilegeKeys.id), + ) + .where( + and( + eq(users.id, userId), + eq(privileges.status, 'active'), + inArray(privilegeKeys.code, [...keyCodes]), + eq(privilegeDetails.action, action), + eq(privilegeDetails.value, true), + ), + ) + .limit(1); + return row?.value === true; + } + async getPermissionsMap( privilegeId: string, ): Promise>> { diff --git a/src/modules/privileges/privileges.service.spec.ts b/src/modules/privileges/privileges.service.spec.ts index d652c2f..3fb7732 100644 --- a/src/modules/privileges/privileges.service.spec.ts +++ b/src/modules/privileges/privileges.service.spec.ts @@ -23,6 +23,7 @@ describe('PrivilegesService', () => { | 'listKeys' | 'findKeyById' | 'checkPermission' + | 'checkAnyPermission' | 'getPermissionsMap' > >; @@ -56,6 +57,7 @@ describe('PrivilegesService', () => { listKeys: jest.fn(), findKeyById: jest.fn(), checkPermission: jest.fn(), + checkAnyPermission: jest.fn(), getPermissionsMap: jest.fn(), }; @@ -140,7 +142,44 @@ describe('PrivilegesService', () => { it('checkPermission delegates', async () => { repository.checkPermission.mockResolvedValue(true); await expect( - service.checkPermission('user-1', 'PRIVILEGES', 'view'), + service.checkPermission( + 'user-1', + 'ADMIN.SETTINGS.USER.PRIVILEGES', + 'view', + ), ).resolves.toBe(true); }); + + it('checkAnyPermission delegates for multiple keys', async () => { + repository.checkAnyPermission.mockResolvedValue(true); + await expect( + service.checkAnyPermission( + 'user-1', + ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], + 'view', + ), + ).resolves.toBe(true); + expect(repository.checkAnyPermission).toHaveBeenCalledWith( + 'user-1', + ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], + 'view', + ); + }); + + it('checkAnyPermission uses checkPermission for a single key', async () => { + repository.checkPermission.mockResolvedValue(true); + await expect( + service.checkAnyPermission( + 'user-1', + ['MOBILE.SALES.PLAN'], + 'view', + ), + ).resolves.toBe(true); + expect(repository.checkPermission).toHaveBeenCalledWith( + 'user-1', + 'MOBILE.SALES.PLAN', + 'view', + ); + expect(repository.checkAnyPermission).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/privileges/privileges.service.ts b/src/modules/privileges/privileges.service.ts index 188278e..3254d5e 100644 --- a/src/modules/privileges/privileges.service.ts +++ b/src/modules/privileges/privileges.service.ts @@ -262,6 +262,24 @@ export class PrivilegesService { return this.privilegesRepository.checkPermission(userId, keyCode, action); } + async checkAnyPermission( + userId: string, + keyCodes: readonly string[], + action: PrivilegeAction, + ): Promise { + if (keyCodes.length === 0) { + return false; + } + if (keyCodes.length === 1) { + return this.checkPermission(userId, keyCodes[0], action); + } + return this.privilegesRepository.checkAnyPermission( + userId, + keyCodes, + action, + ); + } + async getPermissionsMap( privilegeId: string, ): Promise>> { diff --git a/src/modules/reports/shared/configs/logistics-reports.ts b/src/modules/reports/shared/configs/logistics-reports.ts index 103b994..deeaa0a 100644 --- a/src/modules/reports/shared/configs/logistics-reports.ts +++ b/src/modules/reports/shared/configs/logistics-reports.ts @@ -12,7 +12,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`; export const packingSlipReport: ReportConfigEntity = { groupName: REPORT_GROUP.LOGISTICS_REPORT, uniqueName: `${LOGISTICS_PREFIX}packing_slip`, - privilegeKey: 'LOGISTICS.REPORT', + privilegeKey: 'ADMIN.LOGISTICS.REPORT', label: 'Report Packing Slip', tableSchema: `packing_slips main JOIN customers cust ON cust.id = main.customer_id`, @@ -89,7 +89,7 @@ export const packingSlipReport: ReportConfigEntity = { export const deliveryPlanReport: ReportConfigEntity = { groupName: REPORT_GROUP.LOGISTICS_REPORT, uniqueName: `${LOGISTICS_PREFIX}delivery_plan`, - privilegeKey: 'LOGISTICS.REPORT', + privilegeKey: 'ADMIN.LOGISTICS.REPORT', label: 'Report Delivery Plan', tableSchema: `plans main JOIN employees emp ON emp.id = main.employee_id diff --git a/src/modules/reports/shared/configs/sales-reports.ts b/src/modules/reports/shared/configs/sales-reports.ts index 09bb76b..5b86a8c 100644 --- a/src/modules/reports/shared/configs/sales-reports.ts +++ b/src/modules/reports/shared/configs/sales-reports.ts @@ -13,7 +13,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`; export const salesOrderReport: ReportConfigEntity = { groupName: REPORT_GROUP.SALES_REPORT, uniqueName: `${SALES_PREFIX}sales_order`, - privilegeKey: 'SALES.REPORT', + privilegeKey: 'ADMIN.SALES.REPORT', label: 'Report Sales Order', tableSchema: `sales_orders main JOIN customers cust ON cust.id = main.customer_id @@ -138,7 +138,7 @@ export const salesOrderReport: ReportConfigEntity = { export const salesRequestReport: ReportConfigEntity = { groupName: REPORT_GROUP.SALES_REPORT, uniqueName: `${SALES_PREFIX}sales_request`, - privilegeKey: 'SALES.REPORT', + privilegeKey: 'ADMIN.SALES.REPORT', label: 'Report Request Order', tableSchema: `sales_requests main JOIN customers cust ON cust.id = main.customer_id @@ -250,7 +250,7 @@ export const salesRequestReport: ReportConfigEntity = { export const salesInvoiceReport: ReportConfigEntity = { groupName: REPORT_GROUP.SALES_REPORT, uniqueName: `${SALES_PREFIX}sales_invoice`, - privilegeKey: 'SALES.REPORT', + privilegeKey: 'ADMIN.SALES.REPORT', label: 'Report Invoice', tableSchema: `sales_invoices main JOIN customers cust ON cust.id = main.customer_id @@ -365,7 +365,7 @@ export const salesInvoiceReport: ReportConfigEntity = { export const salesPaymentReport: ReportConfigEntity = { groupName: REPORT_GROUP.SALES_REPORT, uniqueName: `${SALES_PREFIX}sales_payment`, - privilegeKey: 'SALES.REPORT', + privilegeKey: 'ADMIN.SALES.REPORT', label: 'Report Payment', tableSchema: `sales_payments main JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id @@ -476,7 +476,7 @@ export const salesPaymentReport: ReportConfigEntity = { export const visitPlanReport: ReportConfigEntity = { groupName: REPORT_GROUP.SALES_REPORT, uniqueName: `${SALES_PREFIX}visit_plan`, - privilegeKey: 'SALES.REPORT', + privilegeKey: 'ADMIN.SALES.REPORT', label: 'Report Visit Plan', tableSchema: `plans main JOIN employees emp ON emp.id = main.employee_id diff --git a/src/modules/sales/packing-slips/packing-slips-read.controller.ts b/src/modules/sales/packing-slips/packing-slips-read.controller.ts index c2ee727..2a39e5e 100644 --- a/src/modules/sales/packing-slips/packing-slips-read.controller.ts +++ b/src/modules/sales/packing-slips/packing-slips-read.controller.ts @@ -21,7 +21,7 @@ import { } from './dto/packing-slip.dto'; import { PackingSlipsService } from './packing-slips.service'; -export const PACKING_SLIP_PRIVILEGE_KEY = 'SALES.PACKING_SLIP'; +export const PACKING_SLIP_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP'; @ApiTags('packing-slips') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts b/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts index 0f6b251..675742c 100644 --- a/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts +++ b/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts @@ -21,7 +21,10 @@ import { } from './dto/sales-invoice.dto'; import { SalesInvoicesService } from './sales-invoices.service'; -export const SALES_INVOICE_PRIVILEGE_KEY = 'SALES.INVOICE'; +export const SALES_INVOICE_PRIVILEGE_KEYS = [ + 'ADMIN.SALES.ACTIVITIES.INVOICE', + 'MOBILE.SALES.INVOICE', +] as const; @ApiTags('sales-invoices') @ApiBearerAuth(BEARER_AUTH_NAME) @@ -31,7 +34,7 @@ export class SalesInvoicesReadController { @Get() @Pagination() - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'List sales invoices' }) @ApiOkResponse({ schema: { @@ -53,7 +56,7 @@ export class SalesInvoicesReadController { } @Get(':id') - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'Get sales invoice detail' }) @ApiOkResponse({ type: SalesInvoiceDto }) @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts index 08ff597..597f905 100644 --- a/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts +++ b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts @@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator'; import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { isAllowedCsvUpload } from '../shared/sales-fields'; -import { SALES_INVOICE_PRIVILEGE_KEY } from './sales-invoices-read.controller'; +import { SALES_INVOICE_PRIVILEGE_KEYS } from './sales-invoices-read.controller'; import { SalesInvoicesService } from './sales-invoices.service'; import { BulkIdsDto, @@ -47,7 +47,7 @@ export class SalesInvoicesWriteController { constructor(private readonly salesInvoicesService: SalesInvoicesService) {} @Post('import') - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'import') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -86,7 +86,7 @@ export class SalesInvoicesWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Bulk delete sales invoices' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -99,7 +99,7 @@ export class SalesInvoicesWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Bulk update sales invoice status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -118,7 +118,7 @@ export class SalesInvoicesWriteController { } @Post() - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'create') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'create') @ApiOperation({ summary: 'Create sales invoice' }) @ApiCreatedResponse({ type: SalesInvoiceDto }) @ApiUnauthorizedResponse() @@ -147,7 +147,7 @@ export class SalesInvoicesWriteController { } @Patch(':id/status') - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales invoice status' }) @ApiOkResponse({ type: SalesInvoiceDto }) @ApiNotFoundResponse() @@ -162,7 +162,7 @@ export class SalesInvoicesWriteController { } @Patch(':id') - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales invoice (not status)' }) @ApiOkResponse({ type: SalesInvoiceDto }) @ApiNotFoundResponse() @@ -193,7 +193,7 @@ export class SalesInvoicesWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Delete sales invoice' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-orders/sales-orders-read.controller.ts b/src/modules/sales/sales-orders/sales-orders-read.controller.ts index 1872181..6e2dcba 100644 --- a/src/modules/sales/sales-orders/sales-orders-read.controller.ts +++ b/src/modules/sales/sales-orders/sales-orders-read.controller.ts @@ -18,7 +18,10 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { SalesOrderDto, ListSalesOrdersQueryDto } from './dto/sales-order.dto'; import { SalesOrdersService } from './sales-orders.service'; -export const SALES_ORDER_PRIVILEGE_KEY = 'SALES.ORDER'; +export const SALES_ORDER_PRIVILEGE_KEYS = [ + 'ADMIN.SALES.ACTIVITIES.ORDER', + 'MOBILE.SALES.ORDER', +] as const; @ApiTags('sales-orders') @ApiBearerAuth(BEARER_AUTH_NAME) @@ -28,7 +31,7 @@ export class SalesOrdersReadController { @Get() @Pagination() - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'List sales orders' }) @ApiOkResponse({ schema: { @@ -50,7 +53,7 @@ export class SalesOrdersReadController { } @Get(':id') - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'Get sales order detail' }) @ApiOkResponse({ type: SalesOrderDto }) @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-orders/sales-orders-write.controller.ts b/src/modules/sales/sales-orders/sales-orders-write.controller.ts index 488f1ab..aae3aee 100644 --- a/src/modules/sales/sales-orders/sales-orders-write.controller.ts +++ b/src/modules/sales/sales-orders/sales-orders-write.controller.ts @@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator'; import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { isAllowedCsvUpload } from '../shared/sales-fields'; -import { SALES_ORDER_PRIVILEGE_KEY } from './sales-orders-read.controller'; +import { SALES_ORDER_PRIVILEGE_KEYS } from './sales-orders-read.controller'; import { SalesOrdersService } from './sales-orders.service'; import { BulkIdsDto, @@ -47,7 +47,7 @@ export class SalesOrdersWriteController { constructor(private readonly salesOrdersService: SalesOrdersService) {} @Post('import') - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'import') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -86,7 +86,7 @@ export class SalesOrdersWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Bulk delete sales orders' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -99,7 +99,7 @@ export class SalesOrdersWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Bulk update sales order status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -119,7 +119,7 @@ export class SalesOrdersWriteController { } @Post() - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'create') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'create') @ApiOperation({ summary: 'Create sales order' }) @ApiCreatedResponse({ type: SalesOrderDto }) @ApiUnauthorizedResponse() @@ -148,7 +148,7 @@ export class SalesOrdersWriteController { } @Patch(':id/status') - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales order status' }) @ApiOkResponse({ type: SalesOrderDto }) @ApiNotFoundResponse() @@ -165,7 +165,7 @@ export class SalesOrdersWriteController { } @Patch(':id') - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales order (not status)' }) @ApiOkResponse({ type: SalesOrderDto }) @ApiNotFoundResponse() @@ -195,7 +195,7 @@ export class SalesOrdersWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Delete sales order' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-payments/sales-payments-read.controller.ts b/src/modules/sales/sales-payments/sales-payments-read.controller.ts index b371fa5..c18e25e 100644 --- a/src/modules/sales/sales-payments/sales-payments-read.controller.ts +++ b/src/modules/sales/sales-payments/sales-payments-read.controller.ts @@ -21,7 +21,10 @@ import { } from './dto/sales-payment.dto'; import { SalesPaymentsService } from './sales-payments.service'; -export const SALES_PAYMENT_PRIVILEGE_KEY = 'SALES.PAYMENT'; +export const SALES_PAYMENT_PRIVILEGE_KEYS = [ + 'ADMIN.SALES.ACTIVITIES.PAYMENT', + 'MOBILE.SALES.PAYMENT', +] as const; @ApiTags('sales-payments') @ApiBearerAuth(BEARER_AUTH_NAME) @@ -31,7 +34,7 @@ export class SalesPaymentsReadController { @Get() @Pagination() - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'List sales payments' }) @ApiOkResponse({ schema: { @@ -53,7 +56,7 @@ export class SalesPaymentsReadController { } @Get(':id') - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'Get sales payment detail' }) @ApiOkResponse({ type: SalesPaymentDto }) @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-payments/sales-payments-write.controller.ts b/src/modules/sales/sales-payments/sales-payments-write.controller.ts index 84891e8..f2d6c1f 100644 --- a/src/modules/sales/sales-payments/sales-payments-write.controller.ts +++ b/src/modules/sales/sales-payments/sales-payments-write.controller.ts @@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator'; import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { isAllowedCsvUpload } from '../shared/sales-fields'; -import { SALES_PAYMENT_PRIVILEGE_KEY } from './sales-payments-read.controller'; +import { SALES_PAYMENT_PRIVILEGE_KEYS } from './sales-payments-read.controller'; import { SalesPaymentsService } from './sales-payments.service'; import { BulkIdsDto, @@ -47,7 +47,7 @@ export class SalesPaymentsWriteController { constructor(private readonly salesPaymentsService: SalesPaymentsService) {} @Post('import') - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'import') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -86,7 +86,7 @@ export class SalesPaymentsWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Bulk delete sales payments' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -99,7 +99,7 @@ export class SalesPaymentsWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Bulk update sales payment status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -118,7 +118,7 @@ export class SalesPaymentsWriteController { } @Post() - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'create') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'create') @ApiOperation({ summary: 'Create sales payment' }) @ApiCreatedResponse({ type: SalesPaymentDto }) @ApiUnauthorizedResponse() @@ -139,7 +139,7 @@ export class SalesPaymentsWriteController { } @Patch(':id/status') - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales payment status' }) @ApiOkResponse({ type: SalesPaymentDto }) @ApiNotFoundResponse() @@ -154,7 +154,7 @@ export class SalesPaymentsWriteController { } @Patch(':id') - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales payment (not status)' }) @ApiOkResponse({ type: SalesPaymentDto }) @ApiNotFoundResponse() @@ -177,7 +177,7 @@ export class SalesPaymentsWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Delete sales payment' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-requests/sales-requests-read.controller.ts b/src/modules/sales/sales-requests/sales-requests-read.controller.ts index b304215..23c2beb 100644 --- a/src/modules/sales/sales-requests/sales-requests-read.controller.ts +++ b/src/modules/sales/sales-requests/sales-requests-read.controller.ts @@ -21,7 +21,10 @@ import { } from './dto/sales-request.dto'; import { SalesRequestsService } from './sales-requests.service'; -export const SALES_REQUEST_PRIVILEGE_KEY = 'SALES.REQUEST'; +export const SALES_REQUEST_PRIVILEGE_KEYS = [ + 'ADMIN.SALES.ACTIVITIES.REQUEST', + 'MOBILE.SALES.REQUEST', +] as const; @ApiTags('sales-requests') @ApiBearerAuth(BEARER_AUTH_NAME) @@ -31,7 +34,7 @@ export class SalesRequestsReadController { @Get() @Pagination() - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'List sales requests' }) @ApiOkResponse({ schema: { @@ -53,7 +56,7 @@ export class SalesRequestsReadController { } @Get(':id') - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view') @ApiOperation({ summary: 'Get sales request detail' }) @ApiOkResponse({ type: SalesRequestDto }) @ApiNotFoundResponse() diff --git a/src/modules/sales/sales-requests/sales-requests-write.controller.ts b/src/modules/sales/sales-requests/sales-requests-write.controller.ts index 41ff555..1305377 100644 --- a/src/modules/sales/sales-requests/sales-requests-write.controller.ts +++ b/src/modules/sales/sales-requests/sales-requests-write.controller.ts @@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator'; import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator'; import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger'; import { isAllowedCsvUpload } from '../shared/sales-fields'; -import { SALES_REQUEST_PRIVILEGE_KEY } from './sales-requests-read.controller'; +import { SALES_REQUEST_PRIVILEGE_KEYS } from './sales-requests-read.controller'; import { SalesRequestsService } from './sales-requests.service'; import { BulkIdsDto, @@ -47,7 +47,7 @@ export class SalesRequestsWriteController { constructor(private readonly salesRequestsService: SalesRequestsService) {} @Post('import') - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'import') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'import') @UseInterceptors( FileInterceptor('file', { limits: { fileSize: 1_048_576 }, @@ -86,7 +86,7 @@ export class SalesRequestsWriteController { @Post('bulk-delete') @HttpCode(200) - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Bulk delete sales requests' }) @ApiOkResponse({ schema: { properties: { deleted: { type: 'number' } } }, @@ -99,7 +99,7 @@ export class SalesRequestsWriteController { @Post('bulk-status') @HttpCode(200) - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Bulk update sales request status' }) @ApiOkResponse({ schema: { properties: { updated: { type: 'number' } } }, @@ -118,7 +118,7 @@ export class SalesRequestsWriteController { } @Post() - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'create') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'create') @ApiOperation({ summary: 'Create sales request' }) @ApiCreatedResponse({ type: SalesRequestDto }) @ApiUnauthorizedResponse() @@ -146,7 +146,7 @@ export class SalesRequestsWriteController { } @Patch(':id/status') - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales request status' }) @ApiOkResponse({ type: SalesRequestDto }) @ApiNotFoundResponse() @@ -161,7 +161,7 @@ export class SalesRequestsWriteController { } @Patch(':id') - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update') @ApiOperation({ summary: 'Update sales request (not status)' }) @ApiOkResponse({ type: SalesRequestDto }) @ApiNotFoundResponse() @@ -191,7 +191,7 @@ export class SalesRequestsWriteController { @Delete(':id') @HttpCode(204) - @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete') + @RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'delete') @ApiOperation({ summary: 'Delete sales request' }) @ApiNoContentResponse() @ApiNotFoundResponse() diff --git a/src/modules/users/users-read.controller.ts b/src/modules/users/users-read.controller.ts index b59c6b6..5a250c0 100644 --- a/src/modules/users/users-read.controller.ts +++ b/src/modules/users/users-read.controller.ts @@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger'; import { ListUsersQueryDto, UserDto } from './dto/user.dto'; import { UsersService } from './users.service'; -export const USERS_PRIVILEGE_KEY = 'USERS'; +export const USERS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.USER.USERS'; @ApiTags('users') @ApiBearerAuth(BEARER_AUTH_NAME) diff --git a/test/privileges.e2e-spec.ts b/test/privileges.e2e-spec.ts index 95b818e..bc35eb1 100644 --- a/test/privileges.e2e-spec.ts +++ b/test/privileges.e2e-spec.ts @@ -115,8 +115,8 @@ describe('Privileges (e2e)', () => { expect(res.body.data).toEqual( expect.arrayContaining([ - expect.objectContaining({ code: 'PRIVILEGES' }), - expect.objectContaining({ code: 'USERS' }), + expect.objectContaining({ code: 'ADMIN.SETTINGS.USER.PRIVILEGES' }), + expect.objectContaining({ code: 'ADMIN.SETTINGS.USER.USERS' }), ]), ); expect(res.body.meta).toMatchObject({ @@ -131,7 +131,7 @@ describe('Privileges (e2e)', () => { .expect(200); const privilegesKey = ( keysRes.body.data as { id: string; code: string }[] - ).find((k) => k.code === 'PRIVILEGES'); + ).find((k) => k.code === 'ADMIN.SETTINGS.USER.PRIVILEGES'); expect(privilegesKey).toBeDefined(); const created = await request(app.getHttpServer()) @@ -156,7 +156,7 @@ describe('Privileges (e2e)', () => { createdBy: adminUserId, details: [ expect.objectContaining({ - keyCode: 'PRIVILEGES', + keyCode: 'ADMIN.SETTINGS.USER.PRIVILEGES', action: 'view', value: true, }), @@ -202,7 +202,7 @@ describe('Privileges (e2e)', () => { expect(me.body.privilege).toMatchObject({ id: adminPrivilegeId, }); - expect(me.body.permissions.PRIVILEGES.view).toBe(true); + expect(me.body.permissions['ADMIN.SETTINGS.USER.PRIVILEGES'].view).toBe(true); await request(app.getHttpServer()) .patch(`/users/${otherUserId}/privilege`)