import { ConflictException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; import { toOrderClauses } from '../../common/http/response'; import { attachAuditUsers } from '../../database/load-user-refs'; import { DateTime } from '../../common/value-objects/date-time/date-time'; import { Status } from '../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../database/database.module'; import { privilegeDetails, privilegeKeys, privileges, users, type PrivilegeKeyRow, type PrivilegeRow, } from '../../database/schema'; import type { PrivilegeAction } from './privilege-action'; import { assertPrivilegeAction } from './privilege-action'; import type { CreatePrivilegeInput, ListPrivilegeKeysFilters, ListPrivilegesFilters, Privilege, PrivilegeDetail, PrivilegeDetailInput, PrivilegeKey, PrivilegeWithDetails, UpdatePrivilegeInput, } from './privilege'; const PRIVILEGE_ORDER_COLUMNS = { id: privileges.id, name: privileges.name, code: privileges.code, status: privileges.status, createdAt: privileges.createdAt, updatedAt: privileges.updatedAt, }; const PRIVILEGE_KEY_ORDER_COLUMNS = { id: privilegeKeys.id, code: privilegeKeys.code, label: privilegeKeys.label, sortOrder: privilegeKeys.sortOrder, }; @Injectable() export class PrivilegesRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} async list( filters: ListPrivilegesFilters, ): Promise<{ data: Privilege[]; total: number }> { const where = this.buildListWhere(filters); const [totalRow] = await this.db .select({ total: count() }) .from(privileges) .where(where); let qb = this.db.select().from(privileges).$dynamic(); qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) .orderBy( ...toOrderClauses(PRIVILEGE_ORDER_COLUMNS, filters, [ { column: 'code', type: 'ASC' }, ]), ) .limit(filters.limit) .offset(filters.offset); return { data: await this.hydrate(rows), total: Number(totalRow?.total ?? 0), }; } /** * Hook for modules to add joins/extra predicates without forking list. */ extendListQuery(qb: T, filters: ListPrivilegesFilters): T { void filters; return qb; } async findById(id: string): Promise { const [row] = await this.db .select() .from(privileges) .where(eq(privileges.id, id)) .limit(1); if (!row) { return null; } const details = await this.loadDetails(id); const [base] = await this.hydrate([row]); return { ...base, details }; } async findByCode(code: string): Promise { const [row] = await this.db .select() .from(privileges) .where(eq(privileges.code, code)) .limit(1); return row ? this.hydrateOne(row) : null; } async create(input: CreatePrivilegeInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = input.status ?? Status.create(Status.DEFAULT); try { return await this.db.transaction(async (tx) => { const [row] = await tx .insert(privileges) .values({ name: input.name, code: input.code, status: status.value, createdAt: now.value, updatedAt: now.value, createdBy: input.userId, updatedBy: input.userId, }) .returning(); if (input.details?.length) { await tx.insert(privilegeDetails).values( input.details.map((d) => ({ privilegeId: row.id, privilegeKeyId: d.privilegeKeyId, action: d.action, value: d.value, })), ); } const details = await this.loadDetails(row.id, tx); const [base] = await this.hydrate([row]); return { ...base, details }; }); } catch (error) { this.rethrowUniqueViolation(error); } } async createMany(inputs: CreatePrivilegeInput[]): Promise { if (inputs.length === 0) { return 0; } const now = DateTime.fromUnixMs(Date.now()); try { await this.db.transaction(async (tx) => { for (const input of inputs) { const status = input.status ?? Status.create(Status.DEFAULT); await tx.insert(privileges).values({ name: input.name, code: input.code, status: status.value, createdAt: now.value, updatedAt: now.value, createdBy: input.userId, updatedBy: input.userId, }); } }); return inputs.length; } catch (error) { this.rethrowUniqueViolation(error); } } async update( id: string, input: UpdatePrivilegeInput, ): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Privilege not found'); } const now = DateTime.fromUnixMs(Date.now()); try { return await this.db.transaction(async (tx) => { const [row] = await tx .update(privileges) .set({ name: input.name ?? existing.name, code: input.code ?? existing.code, updatedAt: now.value, updatedBy: input.userId, }) .where(eq(privileges.id, id)) .returning(); if (input.details !== undefined) { await tx .delete(privilegeDetails) .where(eq(privilegeDetails.privilegeId, id)); if (input.details.length > 0) { await tx.insert(privilegeDetails).values( input.details.map((d) => ({ privilegeId: id, privilegeKeyId: d.privilegeKeyId, action: d.action, value: d.value, })), ); } } const details = await this.loadDetails(id, tx); const [base] = await this.hydrate([row]); return { ...base, details }; }); } catch (error) { this.rethrowUniqueViolation(error); } } async updateStatus( id: string, status: Status, userId: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); const [row] = await this.db .update(privileges) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(privileges.id, id)) .returning(); if (!row) { throw new NotFoundException('Privilege not found'); } return this.hydrateOne(row); } async bulkUpdateStatus( ids: string[], status: Status, userId: string, ): Promise { if (ids.length === 0) { return 0; } const now = DateTime.fromUnixMs(Date.now()); const rows = await this.db .update(privileges) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(privileges.id, ids)) .returning({ id: privileges.id }); return rows.length; } async delete(id: string): Promise { const assigned = await this.countUsersWithPrivilege(id); if (assigned > 0) { throw new ConflictException('Privilege is assigned to users'); } const deleted = await this.db .delete(privileges) .where(eq(privileges.id, id)) .returning({ id: privileges.id }); if (deleted.length === 0) { throw new NotFoundException('Privilege not found'); } } async bulkDelete(ids: string[]): Promise { if (ids.length === 0) { return 0; } for (const id of ids) { const assigned = await this.countUsersWithPrivilege(id); if (assigned > 0) { throw new ConflictException('Privilege is assigned to users'); } } const deleted = await this.db .delete(privileges) .where(inArray(privileges.id, ids)) .returning({ id: privileges.id }); return deleted.length; } async countUsersWithPrivilege(privilegeId: string): Promise { const [row] = await this.db .select({ total: count() }) .from(users) .where(eq(users.privilegeId, privilegeId)); return Number(row?.total ?? 0); } async listKeys( filters: ListPrivilegeKeysFilters, ): Promise<{ data: PrivilegeKey[]; total: number }> { const where = filters.search ? or( ilike(privilegeKeys.code, `%${filters.search}%`), ilike(privilegeKeys.label, `%${filters.search}%`), ) : undefined; const [totalRow] = await this.db .select({ total: count() }) .from(privilegeKeys) .where(where); const rows = await this.db .select() .from(privilegeKeys) .where(where) .orderBy( ...toOrderClauses(PRIVILEGE_KEY_ORDER_COLUMNS, filters, [ { column: 'sortOrder', type: 'ASC' }, { column: 'code', type: 'ASC' }, ]), ) .limit(filters.limit) .offset(filters.offset); return { data: rows.map((row) => this.toKeyDomain(row)), total: Number(totalRow?.total ?? 0), }; } async findKeyById(id: string): Promise { const [row] = await this.db .select() .from(privilegeKeys) .where(eq(privilegeKeys.id, id)) .limit(1); return row ? this.toKeyDomain(row) : null; } async findKeyByCode(code: string): Promise { const [row] = await this.db .select() .from(privilegeKeys) .where(eq(privilegeKeys.code, code)) .limit(1); return row ? this.toKeyDomain(row) : null; } /** * Returns true only when the user's assigned privilege has value=true * for the given key code and action. */ async checkPermission( userId: string, keyCode: string, action: PrivilegeAction, ): Promise { 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'), eq(privilegeKeys.code, keyCode), eq(privilegeDetails.action, action), eq(privilegeDetails.value, true), ), ) .limit(1); return row?.value === true; } async checkAnyPermission( userId: string, keyCodes: readonly string[], action: PrivilegeAction, ): Promise { 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>> { const rows = await this.db .select({ code: privilegeKeys.code, action: privilegeDetails.action, value: privilegeDetails.value, }) .from(privilegeDetails) .innerJoin( privilegeKeys, eq(privilegeDetails.privilegeKeyId, privilegeKeys.id), ) .where(eq(privilegeDetails.privilegeId, privilegeId)); const map: Record> = {}; for (const row of rows) { const action = assertPrivilegeAction(row.action); const current = map[row.code] ?? { view: false, create: false, update: false, delete: false, import: false, }; map[row.code] = { ...current, [action]: row.value }; } return map; } private buildListWhere(filters: ListPrivilegesFilters): SQL | undefined { const parts: SQL[] = []; if (filters.name) { parts.push(ilike(privileges.name, `%${filters.name}%`)); } if (filters.code) { parts.push(ilike(privileges.code, `%${filters.code}%`)); } if (filters.status) { parts.push(eq(privileges.status, filters.status)); } if (filters.search) { const search = or( ilike(privileges.name, `%${filters.search}%`), ilike(privileges.code, `%${filters.search}%`), ); if (search) { parts.push(search); } } if (parts.length === 0) { return undefined; } return parts.length === 1 ? parts[0] : and(...parts); } private async loadDetails( privilegeId: string, tx: DrizzleDB | Parameters[0]>[0] = this .db, ): Promise { const rows = await tx .select({ id: privilegeDetails.id, privilegeKeyId: privilegeDetails.privilegeKeyId, keyCode: privilegeKeys.code, keyLabel: privilegeKeys.label, sortOrder: privilegeKeys.sortOrder, action: privilegeDetails.action, value: privilegeDetails.value, }) .from(privilegeDetails) .innerJoin( privilegeKeys, eq(privilegeDetails.privilegeKeyId, privilegeKeys.id), ) .where(eq(privilegeDetails.privilegeId, privilegeId)) .orderBy(asc(privilegeKeys.sortOrder), asc(privilegeDetails.action)); return rows.map((row) => ({ id: row.id, privilegeKeyId: row.privilegeKeyId, keyCode: row.keyCode, keyLabel: row.keyLabel, sortOrder: row.sortOrder, action: assertPrivilegeAction(row.action), value: row.value, })); } private toDomain(row: PrivilegeRow) { return { id: row.id, name: row.name, code: row.code, status: Status.create(row.status), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, }; } private async hydrate(rows: PrivilegeRow[]): Promise { return attachAuditUsers( this.db, rows.map((row) => this.toDomain(row)), ); } private async hydrateOne(row: PrivilegeRow): Promise { const [item] = await this.hydrate([row]); return item; } private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey { return { id: row.id, code: row.code, label: row.label, sortOrder: row.sortOrder, }; } private rethrowUniqueViolation(error: unknown): never { const err = error as { code?: string; constraint?: string }; if (err.code === '23505') { if (err.constraint?.includes('privilege_details')) { throw new ConflictException('Duplicate privilege detail'); } throw new ConflictException('Privilege code already exists'); } throw error; } } export type { PrivilegeDetailInput };