import { ConflictException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; import { toOrderClauses } from '../../../common/http/response'; 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 { attachAuditUsers } from '../../../database/load-user-refs'; import { divisions, type DivisionRow } from '../../../database/schema'; import type { CreateDivisionInput, Division, ListDivisionsFilters, UpdateDivisionInput, } from './division'; const DIVISION_ORDER_COLUMNS = { id: divisions.id, name: divisions.name, code: divisions.code, status: divisions.status, createdAt: divisions.createdAt, updatedAt: divisions.updatedAt, }; @Injectable() export class DivisionsRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} async list( filters: ListDivisionsFilters, ): Promise<{ data: Division[]; total: number }> { const where = this.buildListWhere(filters); const totalRows = await this.db .select({ total: count() }) .from(divisions) .where(where); const totalRow = totalRows[0]; let qb = this.db.select().from(divisions).$dynamic(); qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) .orderBy( ...toOrderClauses(DIVISION_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: ListDivisionsFilters): T { void filters; return qb; } async findById(id: string): Promise { const rows: DivisionRow[] = await this.db .select() .from(divisions) .where(eq(divisions.id, id)) .limit(1); const row = rows[0]; return row ? this.hydrateOne(row) : null; } async findByCode(code: string): Promise { const rows = await this.db .select() .from(divisions) .where(eq(divisions.code, code)) .limit(1); const row = rows[0]; return row ? this.hydrateOne(row) : null; } async create(input: CreateDivisionInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = input.status ?? Status.create(Status.DEFAULT); try { const inserted = await this.db .insert(divisions) .values({ name: input.name, code: input.code, status: status.value, createdAt: now.value, updatedAt: now.value, createdBy: input.userId, updatedBy: input.userId, }) .returning(); const row = inserted[0]; return this.hydrateOne(row); } catch (error) { this.rethrowUniqueViolation(error); } } async createMany(inputs: CreateDivisionInput[]): 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(divisions).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: UpdateDivisionInput): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Division not found'); } const now = DateTime.fromUnixMs(Date.now()); try { const updated = await this.db .update(divisions) .set({ name: input.name ?? existing.name, code: input.code ?? existing.code, updatedAt: now.value, updatedBy: input.userId, }) .where(eq(divisions.id, id)) .returning(); const row = updated[0]; return this.hydrateOne(row); } catch (error) { this.rethrowUniqueViolation(error); } } async updateStatus( id: string, status: Status, userId: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(divisions) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(divisions.id, id)) .returning(); const row = updated[0]; if (!row) { throw new NotFoundException('Division 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(divisions) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(divisions.id, ids)) .returning({ id: divisions.id }); return rows.length; } async delete(id: string): Promise { try { const deleted = await this.db .delete(divisions) .where(eq(divisions.id, id)) .returning({ id: divisions.id }); if (deleted.length === 0) { throw new NotFoundException('Division not found'); } } catch (error) { this.rethrowForeignKeyViolation(error); } } async bulkDelete(ids: string[]): Promise { if (ids.length === 0) { return 0; } try { const deleted = await this.db .delete(divisions) .where(inArray(divisions.id, ids)) .returning({ id: divisions.id }); return deleted.length; } catch (error) { this.rethrowForeignKeyViolation(error); } } private buildListWhere(filters: ListDivisionsFilters): SQL | undefined { const parts: SQL[] = []; if (filters.name) { parts.push(ilike(divisions.name, `%${filters.name}%`)); } if (filters.code) { parts.push(ilike(divisions.code, `%${filters.code}%`)); } if (filters.status) { parts.push(eq(divisions.status, filters.status)); } if (filters.search) { const search = or( ilike(divisions.name, `%${filters.search}%`), ilike(divisions.code, `%${filters.search}%`), ); if (search) { parts.push(search); } } if (parts.length === 0) { return undefined; } return parts.length === 1 ? parts[0] : and(...parts); } private async hydrate(rows: DivisionRow[]): Promise { return attachAuditUsers( this.db, rows.map((row) => ({ 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 hydrateOne(row: DivisionRow): Promise { const [item] = await this.hydrate([row]); return item; } private rethrowUniqueViolation(error: unknown): never { const err = error as { code?: string }; if (err.code === '23505') { throw new ConflictException('Division code already exists'); } throw error; } private rethrowForeignKeyViolation(error: unknown): never { const err = error as { code?: string }; if (err.code === '23503') { throw new ConflictException('Division is referenced by other records'); } throw error; } }