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>> { 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> { const privilege = await this.privilegesRepository.findById(id); if (!privilege) { throw new NotFoundException('Privilege not found'); } return this.toDetail(privilege); } async create( input: Omit & { status?: string; details?: readonly { privilegeKeyId: string; action: string; value: boolean; }[]; }, ): Promise> { 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> { 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> { 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 { 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> { 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 { return this.privilegesRepository.checkPermission(userId, keyCode, action); } async getPermissionsMap( privilegeId: string, ): Promise>> { 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 { 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; } }