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:
shancheas
2026-09-01 13:14:58 +07:00
parent 365a37b8d2
commit 51db4f4a4d
45 changed files with 466 additions and 162 deletions
@@ -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);
+41 -13
View File
@@ -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(
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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 }),
},
});
});
+1 -1
View File
@@ -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 = {
+5 -5
View File
@@ -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',
+5 -5
View File
@@ -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', () => {
+16 -13
View File
@@ -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 {
+2 -2
View File
@@ -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);
});
});
});
+20 -2
View File
@@ -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()
+1 -1
View File
@@ -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)