import { ConflictException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { and, asc, count, eq, ilike, inArray, ne, or, sql, SQL, } from 'drizzle-orm'; 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 { planDestinations, planInvoices, planPackingSlips, plans, type PlanDestinationRow, type PlanInvoiceRow, type PlanPackingSlipRow, type PlanRow, } from '../../../database/plans-table'; import type { FieldPurpose } from '../shared/field-purpose'; import type { RouteGeometry } from '../shared/route-line-string'; import type { ListPlansFilters, Plan } from './plan'; type QueryExecutor = Pick; @Injectable() export class PlansRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} async list( filters: ListPlansFilters, ): Promise<{ data: Plan[]; total: number }> { const where = this.buildListWhere(filters); const totalRows = await this.db .select({ total: count() }) .from(plans) .where(where); let qb = this.db.select().from(plans).$dynamic(); qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) .orderBy(asc(plans.date)) .limit(filters.limit) .offset(filters.offset); return { data: rows.map((row) => this.toDomain(row, [], [], [])), total: Number(totalRows[0]?.total ?? 0), }; } extendListQuery(qb: T, filters: ListPlansFilters): T { void filters; return qb; } async findById(id: string): Promise { const rows: PlanRow[] = await this.db .select() .from(plans) .where(eq(plans.id, id)) .limit(1); const row = rows[0]; if (!row) { return null; } return this.loadWithChildren(this.db, row); } async findLiveByKey( employeeId: string, purpose: string, date: number, excludeId?: string, ): Promise { const parts: SQL[] = [ eq(plans.employeeId, employeeId), eq(plans.purpose, purpose), eq(plans.date, date), ne(plans.status, 'archived'), ]; if (excludeId) { parts.push(ne(plans.id, excludeId)); } const rows = await this.db .select() .from(plans) .where(and(...parts)) .limit(1); const row = rows[0]; return row ? this.toDomain(row, [], [], []) : null; } async create(input: { employeeId: string; purpose: FieldPurpose; date: DateTime; startBranchId: string; endBranchId: string; routeGeometry: RouteGeometry; customerIds: readonly string[]; invoiceIds: readonly string[]; packingSlipIds: readonly string[]; status: Status; userId: string; }): Promise { const now = DateTime.fromUnixMs(Date.now()); try { return await this.db.transaction(async (tx) => { const inserted = await tx .insert(plans) .values({ employeeId: input.employeeId, purpose: input.purpose, date: input.date.value, startBranchId: input.startBranchId, endBranchId: input.endBranchId, routeGeometry: input.routeGeometry, status: input.status.value, createdAt: now.value, updatedAt: now.value, createdBy: input.userId, updatedBy: input.userId, }) .returning(); const row = inserted[0]; await this.replaceChildren(tx, row.id, input); return this.loadWithChildren(tx, row); }); } catch (error) { this.rethrowConstraintViolation(error); } } async update( id: string, input: { employeeId: string; purpose: FieldPurpose; date: DateTime; startBranchId: string; endBranchId: string; routeGeometry: RouteGeometry; customerIds?: readonly string[]; invoiceIds?: readonly string[]; packingSlipIds?: readonly string[]; userId: string; }, ): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Plan not found'); } const now = DateTime.fromUnixMs(Date.now()); try { return await this.db.transaction(async (tx) => { const updated = await tx .update(plans) .set({ employeeId: input.employeeId, purpose: input.purpose, date: input.date.value, startBranchId: input.startBranchId, endBranchId: input.endBranchId, routeGeometry: input.routeGeometry, updatedAt: now.value, updatedBy: input.userId, }) .where(eq(plans.id, id)) .returning(); const row = updated[0]; await this.replaceChildren(tx, id, { customerIds: input.customerIds, invoiceIds: input.invoiceIds, packingSlipIds: input.packingSlipIds, }); return this.loadWithChildren(tx, row); }); } catch (error) { this.rethrowConstraintViolation(error); } } async replaceDestinations( id: string, customerIds: readonly string[], routeGeometry: RouteGeometry, userId: string, ): Promise { const existing = await this.findById(id); if (!existing) { throw new NotFoundException('Plan not found'); } const now = DateTime.fromUnixMs(Date.now()); return this.db.transaction(async (tx) => { await tx .update(plans) .set({ routeGeometry, updatedAt: now.value, updatedBy: userId, }) .where(eq(plans.id, id)); await tx.delete(planDestinations).where(eq(planDestinations.planId, id)); if (customerIds.length > 0) { await tx.insert(planDestinations).values( customerIds.map((customerId, index) => ({ planId: id, customerId, sortOrder: index, })), ); } const rows = await tx .select() .from(plans) .where(eq(plans.id, id)) .limit(1); return this.loadWithChildren(tx, rows[0]); }); } async updateStatus( id: string, status: Status, userId: string, ): Promise { const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(plans) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(eq(plans.id, id)) .returning({ id: plans.id }); if (updated.length === 0) { throw new NotFoundException('Plan not found'); } const found = await this.findById(id); if (!found) { throw new NotFoundException('Plan not found'); } return found; } 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(plans) .set({ status: status.value, updatedAt: now.value, updatedBy: userId, }) .where(inArray(plans.id, ids)) .returning({ id: plans.id }); return rows.length; } async archive(id: string, userId: string): Promise { const now = DateTime.fromUnixMs(Date.now()); const updated = await this.db .update(plans) .set({ status: 'archived', updatedAt: now.value, updatedBy: userId, }) .where(eq(plans.id, id)) .returning({ id: plans.id }); if (updated.length === 0) { throw new NotFoundException('Plan not found'); } } async bulkArchive(ids: string[], userId: string): Promise { if (ids.length === 0) { return 0; } const now = DateTime.fromUnixMs(Date.now()); const rows = await this.db .update(plans) .set({ status: 'archived', updatedAt: now.value, updatedBy: userId, }) .where(inArray(plans.id, ids)) .returning({ id: plans.id }); return rows.length; } private async loadWithChildren( executor: QueryExecutor, row: PlanRow, ): Promise { const destinations = await executor .select() .from(planDestinations) .where(eq(planDestinations.planId, row.id)) .orderBy(asc(planDestinations.sortOrder)); const invoices = await executor .select() .from(planInvoices) .where(eq(planInvoices.planId, row.id)); const packingSlips = await executor .select() .from(planPackingSlips) .where(eq(planPackingSlips.planId, row.id)); return this.toDomain(row, destinations, invoices, packingSlips); } private async replaceChildren( executor: QueryExecutor, planId: string, input: { customerIds?: readonly string[]; invoiceIds?: readonly string[]; packingSlipIds?: readonly string[]; }, ): Promise { if (input.customerIds) { await executor .delete(planDestinations) .where(eq(planDestinations.planId, planId)); if (input.customerIds.length > 0) { await executor.insert(planDestinations).values( input.customerIds.map((customerId, index) => ({ planId, customerId, sortOrder: index, })), ); } } if (input.invoiceIds) { await executor .delete(planInvoices) .where(eq(planInvoices.planId, planId)); if (input.invoiceIds.length > 0) { await executor .insert(planInvoices) .values(input.invoiceIds.map((invoiceId) => ({ planId, invoiceId }))); } } if (input.packingSlipIds) { await executor .delete(planPackingSlips) .where(eq(planPackingSlips.planId, planId)); if (input.packingSlipIds.length > 0) { await executor.insert(planPackingSlips).values( input.packingSlipIds.map((packingSlipId) => ({ planId, packingSlipId, })), ); } } } private buildListWhere(filters: ListPlansFilters): SQL | undefined { const parts: SQL[] = []; if (filters.employeeId) { parts.push(eq(plans.employeeId, filters.employeeId)); } if (filters.purpose) { parts.push(eq(plans.purpose, filters.purpose)); } else if (filters.purposes && filters.purposes.length > 0) { parts.push(inArray(plans.purpose, [...filters.purposes])); } if (filters.date !== undefined) { parts.push(eq(plans.date, filters.date)); } if (filters.status) { parts.push(eq(plans.status, filters.status)); } if (filters.search) { const search = or( ilike(plans.purpose, `%${filters.search}%`), sql`${plans.date}::text ilike ${'%' + filters.search + '%'}`, ); if (search) { parts.push(search); } } if (parts.length === 0) { return undefined; } return parts.length === 1 ? parts[0] : and(...parts); } private toDomain( row: PlanRow, destinationRows: PlanDestinationRow[], invoiceRows: PlanInvoiceRow[], packingSlipRows: PlanPackingSlipRow[], ): Plan { return { id: row.id, employeeId: row.employeeId, purpose: row.purpose as FieldPurpose, date: DateTime.fromUnixMs(row.date), startBranchId: row.startBranchId, endBranchId: row.endBranchId, routeGeometry: row.routeGeometry, destinations: destinationRows.map((destination) => ({ id: destination.id, customerId: destination.customerId, sortOrder: destination.sortOrder, })), invoiceIds: invoiceRows.map((row) => row.invoiceId), packingSlipIds: packingSlipRows.map((row) => row.packingSlipId), status: Status.create(row.status), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, }; } private rethrowConstraintViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { throw new ConflictException('Plan already exists for this employee'); } if (err.code === '23503') { throw new ConflictException('Plan references a missing record'); } throw error; } private unwrapDbError(error: unknown): { code?: string } { let current: unknown = error; for (let i = 0; i < 5; i++) { if (!current || typeof current !== 'object') { break; } const obj = current as { code?: string; cause?: unknown }; if (obj.code === '23505' || obj.code === '23503') { return { code: obj.code }; } current = obj.cause; } return error as { code?: string }; } }