Add privilege management system with related migrations and guards
- Introduced a new `PrivilegesModule` to manage user privileges and access control. - Added `RequirePrivilege` decorator to enforce privilege checks on controller handlers. - Implemented `PrivilegesGuard` to handle authorization based on user privileges. - Created database migrations for `privileges`, `privilege_keys`, and `privilege_details` tables. - Updated user model to include `is_superadmin` field for enhanced access control. - Added unit tests for the new privileges functionality and guards to ensure correct behavior.
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../common/http/response';
|
||||
import { toListPage } from '../../common/http/response';
|
||||
import { Status } from '../../common/value-objects/status/status';
|
||||
import type { PrivilegeAction } from './privilege-action';
|
||||
import { assertPrivilegeAction } from './privilege-action';
|
||||
import type {
|
||||
CreatePrivilegeInput,
|
||||
Privilege,
|
||||
PrivilegeDetailInput,
|
||||
PrivilegeKey,
|
||||
PrivilegeWithDetails,
|
||||
UpdatePrivilegeInput,
|
||||
} from './privilege';
|
||||
import { PrivilegesRepository } from './privileges.repository';
|
||||
|
||||
export type ListPrivilegesQuery = {
|
||||
readonly name?: string;
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
export type ListPrivilegeKeysQuery = {
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'name',
|
||||
'code',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class PrivilegesService {
|
||||
constructor(private readonly privilegesRepository: PrivilegesRepository) {}
|
||||
|
||||
async list(
|
||||
query: ListPrivilegesQuery,
|
||||
): Promise<PaginationResponse<ReturnType<PrivilegesService['toListItem']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.privilegesRepository.list({
|
||||
name: query.name,
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||
const privilege = await this.privilegesRepository.findById(id);
|
||||
if (!privilege) {
|
||||
throw new NotFoundException('Privilege not found');
|
||||
}
|
||||
return this.toDetail(privilege);
|
||||
}
|
||||
|
||||
async create(
|
||||
input: Omit<CreatePrivilegeInput, 'status' | 'details'> & {
|
||||
status?: string;
|
||||
details?: readonly {
|
||||
privilegeKeyId: string;
|
||||
action: string;
|
||||
value: boolean;
|
||||
}[];
|
||||
},
|
||||
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||
const details = await this.normalizeDetails(input.details);
|
||||
const created = await this.privilegesRepository.create({
|
||||
name: input.name.trim(),
|
||||
code: input.code.trim(),
|
||||
status: input.status
|
||||
? Status.create(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
details,
|
||||
userId: input.userId,
|
||||
});
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: unknown;
|
||||
details?: readonly {
|
||||
privilegeKeyId: string;
|
||||
action: string;
|
||||
value: boolean;
|
||||
}[];
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<PrivilegesService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const payload: UpdatePrivilegeInput = {
|
||||
name: input.name?.trim(),
|
||||
code: input.code?.trim(),
|
||||
userId: input.userId,
|
||||
details:
|
||||
input.details !== undefined
|
||||
? await this.normalizeDetails(input.details)
|
||||
: undefined,
|
||||
};
|
||||
const updated = await this.privilegesRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<PrivilegesService['toListItem']>> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.privilegesRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.privilegesRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.privilegesRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.privilegesRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const lines = csv
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (lines.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (lines.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
|
||||
const header = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||
const nameIdx = header.indexOf('name');
|
||||
const codeIdx = header.indexOf('code');
|
||||
const statusIdx = header.indexOf('status');
|
||||
if (nameIdx < 0 || codeIdx < 0) {
|
||||
throw new BadRequestException('CSV must include name and code headers');
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const rows: { name: string; code: string; status?: string }[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',').map((c) => c.trim());
|
||||
const name = cols[nameIdx] ?? '';
|
||||
const code = cols[codeIdx] ?? '';
|
||||
const status = statusIdx >= 0 ? cols[statusIdx] : undefined;
|
||||
if (!name || !code) {
|
||||
errors.push(`row ${i + 1}: name and code are required`);
|
||||
continue;
|
||||
}
|
||||
if (name.length > 120 || code.length > 64) {
|
||||
errors.push(`row ${i + 1}: name or code too long`);
|
||||
continue;
|
||||
}
|
||||
if (status) {
|
||||
try {
|
||||
Status.create(status);
|
||||
} catch {
|
||||
errors.push(`row ${i + 1}: invalid status`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rows.push({ name, code, status: status || undefined });
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
await this.privilegesRepository.createMany(
|
||||
rows.map((row) => ({
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
status: row.status
|
||||
? Status.create(row.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
details: [],
|
||||
userId,
|
||||
})),
|
||||
);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
async listKeys(
|
||||
query: ListPrivilegeKeysQuery,
|
||||
): Promise<PaginationResponse<PrivilegeKey>> {
|
||||
const page = toListPage(query);
|
||||
return this.privilegesRepository.listKeys({
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
}
|
||||
|
||||
async checkPermission(
|
||||
userId: string,
|
||||
keyCode: string,
|
||||
action: PrivilegeAction,
|
||||
): Promise<boolean> {
|
||||
return this.privilegesRepository.checkPermission(userId, keyCode, action);
|
||||
}
|
||||
|
||||
async getPermissionsMap(
|
||||
privilegeId: string,
|
||||
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
|
||||
return this.privilegesRepository.getPermissionsMap(privilegeId);
|
||||
}
|
||||
|
||||
async findPrivilegeSummary(privilegeId: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
} | null> {
|
||||
const privilege = await this.privilegesRepository.findById(privilegeId);
|
||||
if (!privilege) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: privilege.id,
|
||||
name: privilege.name,
|
||||
code: privilege.code,
|
||||
status: privilege.status.value,
|
||||
};
|
||||
}
|
||||
|
||||
private async normalizeDetails(
|
||||
details?: readonly {
|
||||
privilegeKeyId: string;
|
||||
action: string;
|
||||
value: boolean;
|
||||
}[],
|
||||
): Promise<PrivilegeDetailInput[] | undefined> {
|
||||
if (details === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: PrivilegeDetailInput[] = [];
|
||||
for (const detail of details) {
|
||||
const key = await this.privilegesRepository.findKeyById(
|
||||
detail.privilegeKeyId,
|
||||
);
|
||||
if (!key) {
|
||||
throw new BadRequestException('Unknown privilege key');
|
||||
}
|
||||
normalized.push({
|
||||
privilegeKeyId: detail.privilegeKeyId,
|
||||
action: assertPrivilegeAction(detail.action),
|
||||
value: detail.value,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
toListItem(privilege: Privilege) {
|
||||
return {
|
||||
id: privilege.id,
|
||||
name: privilege.name,
|
||||
code: privilege.code,
|
||||
status: privilege.status.value,
|
||||
createdAt: privilege.createdAt.value,
|
||||
updatedAt: privilege.updatedAt.value,
|
||||
createdBy: privilege.createdBy,
|
||||
updatedBy: privilege.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(privilege: PrivilegeWithDetails) {
|
||||
return {
|
||||
...this.toListItem(privilege),
|
||||
details: privilege.details.map((d) => ({
|
||||
id: d.id,
|
||||
privilegeKeyId: d.privilegeKeyId,
|
||||
keyCode: d.keyCode,
|
||||
keyLabel: d.keyLabel,
|
||||
sortOrder: d.sortOrder,
|
||||
action: d.action,
|
||||
value: d.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Expose whitelist for tests / documentation. */
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user