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.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -389,6 +389,39 @@ export class PrivilegesRepository {
|
||||
return row?.value === true;
|
||||
}
|
||||
|
||||
async checkAnyPermission(
|
||||
userId: string,
|
||||
keyCodes: readonly string[],
|
||||
action: PrivilegeAction,
|
||||
): Promise<boolean> {
|
||||
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<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +262,24 @@ export class PrivilegesService {
|
||||
return this.privilegesRepository.checkPermission(userId, keyCode, action);
|
||||
}
|
||||
|
||||
async checkAnyPermission(
|
||||
userId: string,
|
||||
keyCodes: readonly string[],
|
||||
action: PrivilegeAction,
|
||||
): Promise<boolean> {
|
||||
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<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user