Files
trackgo-be/src/modules/privileges/privileges.service.ts
T
shancheas 07550b3167 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.
2026-08-24 11:31:13 +07:00

341 lines
9.0 KiB
TypeScript

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;
}
}