import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; import { pickUserRelation, toListPage } from '../../../common/http/response'; import { Status } from '../../../common/value-objects/status/status'; import type { CreateDivisionInput, Division, UpdateDivisionInput, } from './division'; import { isValidDivisionCode, isValidDivisionName } from './division-fields'; import { DivisionsRepository } from './divisions.repository'; export type ListDivisionsQuery = { readonly name?: string; readonly code?: string; readonly status?: string; readonly search?: string; readonly orderBy?: string; readonly orderType?: 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 DivisionsService { constructor(private readonly divisionsRepository: DivisionsRepository) {} async list( query: ListDivisionsQuery, ): Promise>> { const page = toListPage(query); const { data, total } = await this.divisionsRepository.list({ name: query.name, code: query.code, status: query.status, search: query.search, orderBy: query.orderBy, orderType: query.orderType, limit: page.limit, offset: page.offset, }); return { data: data.map((item) => this.toListItem(item)), total, }; } async findById( id: string, ): Promise> { const division = await this.divisionsRepository.findById(id); if (!division) { throw new NotFoundException('Division not found'); } return this.toListItem(division); } async create(input: { name: string; code: string; status?: string; userId: string; }): Promise> { const name = this.assertName(input.name); const code = this.assertCode(input.code); const created = await this.divisionsRepository.create({ name, code, status: input.status ? Status.create(input.status) : Status.create(Status.DEFAULT), userId: input.userId, }); return this.toListItem(created); } async update( id: string, input: { name?: string; code?: string; status?: unknown; userId: string; }, ): Promise> { if (input.status !== undefined) { throw new BadRequestException('status cannot be updated via PATCH'); } const payload: UpdateDivisionInput = { name: input.name !== undefined ? this.assertName(input.name) : undefined, code: input.code !== undefined ? this.assertCode(input.code) : undefined, userId: input.userId, }; const updated = await this.divisionsRepository.update(id, payload); return this.toListItem(updated); } async updateStatus( id: string, statusRaw: string, userId: string, ): Promise> { const status = Status.create(statusRaw); const updated = await this.divisionsRepository.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.divisionsRepository.bulkUpdateStatus( ids, status, userId, ); return { updated }; } async delete(id: string): Promise { await this.divisionsRepository.delete(id); } async bulkDelete(ids: string[]): Promise<{ deleted: number }> { const deleted = await this.divisionsRepository.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 (!isValidDivisionName(name)) { errors.push(`row ${i + 1}: invalid name`); continue; } if (!isValidDivisionCode(code)) { errors.push(`row ${i + 1}: invalid code`); 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, }); } const inputs: CreateDivisionInput[] = rows.map((row) => ({ name: row.name, code: row.code, status: row.status ? Status.create(row.status) : Status.create(Status.DEFAULT), userId, })); await this.divisionsRepository.createMany(inputs); return { imported: rows.length }; } toListItem(division: Division) { return { id: division.id, name: division.name, code: division.code, status: division.status.value, createdAt: division.createdAt.value, updatedAt: division.updatedAt.value, createdBy: pickUserRelation(division.createdByUser), updatedBy: pickUserRelation(division.updatedByUser), }; } get visibleFields(): readonly string[] { return VISIBLE_FIELDS; } private assertName(raw: string): string { const name = raw.trim(); if (!isValidDivisionName(name)) { throw new BadRequestException('Invalid division name'); } return name; } private assertCode(raw: string): string { const code = raw.trim(); if (!isValidDivisionCode(code)) { throw new BadRequestException('Invalid division code'); } return code; } }