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 { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { customerContacts, customers, type CustomerContactRow, type CustomerRow, type NewCustomerRow, } from '../../../database/customers-table'; import type { CreateCustomerInput, Customer, CustomerContact, CustomerContactInput, ListCustomersFilters, UpdateCustomerContactInput, UpdateCustomerInput, } from './customer'; const CUSTOMER_ORDER_COLUMNS = { id: customers.id, code: customers.code, name: customers.name, phone: customers.phone, address: customers.address, nfcId: customers.nfcId, status: customers.status, createdAt: customers.createdAt, updatedAt: customers.updatedAt, }; type QueryExecutor = Pick; @Injectable() export class CustomersRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} async list( filters: ListCustomersFilters, ): Promise<{ data: Customer[]; total: number }> { const where = this.buildListWhere(filters); const totalRows = await this.db .select({ total: count() }) .from(customers) .where(where); const totalRow = totalRows[0]; let qb = this.db.select().from(customers).$dynamic(); qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) .orderBy( ...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [ { column: 'code', type: 'ASC' }, ]), ) .limit(filters.limit) .offset(filters.offset); return { data: await Promise.all(rows.map((row) => this.hydrate(row, []))), total: Number(totalRow?.total ?? 0), }; } /** * Hook for modules to add joins/extra predicates without forking list. */ extendListQuery(qb: T, filters: ListCustomersFilters): T { void filters; return qb; } async findById(id: string): Promise { const rows: CustomerRow[] = await this.db .select() .from(customers) .where(eq(customers.id, id)) .limit(1); const row = rows[0]; if (!row) { return null; } const contacts = await this.selectContacts(this.db, id); return this.hydrate(row, contacts); } async findByCode(code: string): Promise { const rows: CustomerRow[] = await this.db .select() .from(customers) .where(eq(customers.code, code)) .limit(1); const row = rows[0]; if (!row) { return null; } const contacts = await this.selectContacts(this.db, row.id); return this.hydrate(row, contacts); } async create(input: CreateCustomerInput): Promise { const now = DateTime.fromUnixMs(Date.now()); const status = input.status ?? Status.create(Status.DEFAULT); try { return await this.db.transaction(async (tx) => { const inserted = await tx .insert(customers) .values(this.toInsertValues(input, status, now, input.userId)) .returning(); const row = inserted[0]; await this.replaceContacts(tx, row.id, input.contacts ?? []); const contacts = await this.selectContacts(tx, row.id); return this.hydrate(row, contacts); }); } catch (error) { this.rethrowConstraintViolation(error); } } async createMany(inputs: CreateCustomerInput[]): 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); const inserted = await tx .insert(customers) .values(this.toInsertValues(input, status, now, input.userId)) .returning(); const row = inserted[0]; await this.replaceContacts(tx, row.id, input.contacts ?? []); } }); return inputs.length; } catch (error) { this.rethrowConstraintViolation(error); } } async update(id: string, input: UpdateCustomerInput): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Customer not found'); } const now = DateTime.fromUnixMs(Date.now()); try { return await this.db.transaction(async (tx) => { const values: Partial = { code: input.code ?? existing.code, name: input.name ?? existing.name, phone: input.phone?.value ?? existing.phone.value, address: input.address ?? existing.address, latitude: input.latitude !== undefined ? input.latitude : existing.latitude, longitude: input.longitude !== undefined ? input.longitude : existing.longitude, nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId, updatedAt: now.value, updatedBy: input.userId, }; const updated = await tx .update(customers) .set(values) .where(eq(customers.id, id)) .returning(); const row = updated[0]; if (!row) { throw new NotFoundException('Customer not found'); } if (input.contacts !== undefined) { await this.replaceContacts(tx, id, input.contacts); } const contacts = await this.selectContacts(tx, id); return this.hydrate(row, contacts); }); } catch (error) { this.rethrowConstraintViolation(error); } } async updateStatus( id: string, status: Status, userId: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(customers) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(customers.id, id)) .returning(); const row = updated[0]; if (!row) { throw new NotFoundException('Customer not found'); } const contacts = await this.selectContacts(this.db, id); return this.hydrate(row, contacts); } 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(customers) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(customers.id, ids)) .returning({ id: customers.id }); return rows.length; } async delete(id: string): Promise { const deleted = await this.db .delete(customers) .where(eq(customers.id, id)) .returning({ id: customers.id }); if (deleted.length === 0) { throw new NotFoundException('Customer not found'); } } async bulkDelete(ids: string[]): Promise { if (ids.length === 0) { return 0; } const deleted = await this.db .delete(customers) .where(inArray(customers.id, ids)) .returning({ id: customers.id }); return deleted.length; } async addContact( customerId: string, input: CustomerContactInput, userId: string, ): Promise { const existing = await this.findById(customerId); if (!existing) { throw new NotFoundException('Customer not found'); } const now = DateTime.fromUnixMs(Date.now()); await this.db.insert(customerContacts).values({ customerId, name: input.name, jobTitle: input.jobTitle ?? null, phone: input.phone?.value ?? null, mobilePhone: input.mobilePhone?.value ?? null, notes: input.notes ?? null, }); await this.touchCustomer(customerId, userId, now); const found = await this.findById(customerId); if (!found) { throw new NotFoundException('Customer not found'); } return found; } async updateContact( customerId: string, contactId: string, input: UpdateCustomerContactInput, ): Promise { const existing = await this.findById(customerId); if (!existing) { throw new NotFoundException('Customer not found'); } const current = existing.contacts.find((c) => c.id === contactId); if (!current) { throw new NotFoundException('Contact not found'); } const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(customerContacts) .set({ name: input.name ?? current.name, jobTitle: input.jobTitle !== undefined ? input.jobTitle : current.jobTitle, phone: input.phone !== undefined ? (input.phone?.value ?? null) : (current.phone?.value ?? null), mobilePhone: input.mobilePhone !== undefined ? (input.mobilePhone?.value ?? null) : (current.mobilePhone?.value ?? null), notes: input.notes !== undefined ? input.notes : current.notes, }) .where( and( eq(customerContacts.id, contactId), eq(customerContacts.customerId, customerId), ), ) .returning({ id: customerContacts.id }); if (updated.length === 0) { throw new NotFoundException('Contact not found'); } await this.touchCustomer(customerId, input.userId, now); const found = await this.findById(customerId); if (!found) { throw new NotFoundException('Customer not found'); } return found; } async deleteContact(customerId: string, contactId: string): Promise { const deleted = await this.db .delete(customerContacts) .where( and( eq(customerContacts.id, contactId), eq(customerContacts.customerId, customerId), ), ) .returning({ id: customerContacts.id }); if (deleted.length === 0) { throw new NotFoundException('Contact not found'); } } private async touchCustomer( customerId: string, userId: string, now: DateTime, ): Promise { await this.db .update(customers) .set({ updatedAt: now.value, updatedBy: userId, }) .where(eq(customers.id, customerId)); } private async selectContacts( executor: QueryExecutor, customerId: string, ): Promise { return executor .select() .from(customerContacts) .where(eq(customerContacts.customerId, customerId)) .orderBy(asc(customerContacts.name)); } private async replaceContacts( executor: QueryExecutor, customerId: string, contacts: readonly CustomerContactInput[], ): Promise { await executor .delete(customerContacts) .where(eq(customerContacts.customerId, customerId)); if (contacts.length === 0) { return; } await executor.insert(customerContacts).values( contacts.map((contact) => ({ customerId, name: contact.name, jobTitle: contact.jobTitle ?? null, phone: contact.phone?.value ?? null, mobilePhone: contact.mobilePhone?.value ?? null, notes: contact.notes ?? null, })), ); } private buildListWhere(filters: ListCustomersFilters): SQL | undefined { const parts: SQL[] = []; if (filters.code) { parts.push(ilike(customers.code, `%${filters.code}%`)); } if (filters.name) { parts.push(ilike(customers.name, `%${filters.name}%`)); } if (filters.phone) { parts.push(ilike(customers.phone, `%${filters.phone}%`)); } if (filters.address) { parts.push(ilike(customers.address, `%${filters.address}%`)); } if (filters.nfcId) { parts.push(eq(customers.nfcId, filters.nfcId)); } if (filters.status) { parts.push(eq(customers.status, filters.status)); } if (filters.search) { const search = or( ilike(customers.code, `%${filters.search}%`), ilike(customers.name, `%${filters.search}%`), ilike(customers.address, `%${filters.search}%`), ); if (search) { parts.push(search); } } if (parts.length === 0) { return undefined; } return parts.length === 1 ? parts[0] : and(...parts); } private toInsertValues( input: CreateCustomerInput, status: Status, now: DateTime, userId: string, ) { return { code: input.code, name: input.name, phone: input.phone.value, address: input.address, latitude: input.latitude ?? null, longitude: input.longitude ?? null, nfcId: input.nfcId ?? null, status: status.value, createdAt: now.value, updatedAt: now.value, createdBy: userId, updatedBy: userId, }; } private async hydrate( row: CustomerRow, contactRows: CustomerContactRow[], ): Promise { const [item] = await attachAuditUsers(this.db, [ this.toDomain(row, contactRows), ]); return item; } private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) { return { id: row.id, code: row.code, name: row.name, phone: PhoneNumber.create(row.phone), address: row.address, latitude: row.latitude, longitude: row.longitude, nfcId: row.nfcId, contacts: contactRows.map((contact) => this.toContactDomain(contact)), status: Status.create(row.status), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, }; } private toContactDomain(row: CustomerContactRow): CustomerContact { return { id: row.id, customerId: row.customerId, name: row.name, jobTitle: row.jobTitle, phone: row.phone ? PhoneNumber.create(row.phone) : null, mobilePhone: row.mobilePhone ? PhoneNumber.create(row.mobilePhone) : null, notes: row.notes, }; } private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; } const err = this.unwrapDbError(error); if (err.code === '23505') { const constraint = err.constraint ?? ''; if (constraint.includes('nfc')) { throw new ConflictException('Customer NFC ID already exists'); } throw new ConflictException('Customer code already exists'); } throw error; } private unwrapDbError(error: unknown): { code?: string; constraint?: string; } { let current: unknown = error; for (let i = 0; i < 5; i++) { if (!current || typeof current !== 'object') { break; } const obj = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown; }; if (obj.code === '23505' || obj.code === '23503') { return { code: obj.code, constraint: obj.constraint ?? obj.constraint_name, }; } current = obj.cause; } return error as { code?: string; constraint?: string }; } }