File

src/clients/clients.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService)
Parameters :
Name Type Optional
prisma PrismaService No

Methods

Async bulkUploadFromFile
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
Parameters :
Name Type Optional
organizationId string No
file Express.Multer.File No
Async create
create(organizationId: string, dto: CreateClientDto)
Parameters :
Name Type Optional
organizationId string No
dto CreateClientDto No
Returns : unknown
Async findAll
findAll(organizationId: string, params: PaginationParams)
Parameters :
Name Type Optional
organizationId string No
params PaginationParams No
Returns : unknown
Async findOne
findOne(organizationId: string, id: string)

Org-scoped lookup — cross-tenant ID guessing impossible.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async remove
remove(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateClientDto)
Parameters :
Name Type Optional
organizationId string No
id string No
dto UpdateClientDto No
Returns : unknown
import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
  PaginationParams,
} from '../common/utils/pagination.util';
import { BulkUploadService, CLIENT_MAPPINGS, BulkUploadResult } from '../common/services/bulk-upload.service';

@Injectable()
export class ClientsService {
  private readonly logger = new Logger(ClientsService.name);
  private readonly bulkUpload = new BulkUploadService();

  constructor(private prisma: PrismaService) {}

  async create(organizationId: string, dto: CreateClientDto) {
    // Per-org uniqueness on code (composite unique backs this).
    const existing = await this.prisma.client.findFirst({
      where: { organizationId, code: dto.code },
    });
    if (existing) {
      throw new ConflictException(`A client with code "${dto.code}" already exists in this organization`);
    }

    return this.prisma.client.create({
      data: {
        organizationId,
        ...dto,
      },
    });
  }

  async findAll(organizationId: string, params: PaginationParams) {
    const { skip, take, orderBy, page, limit } = buildPaginationQuery(params);

    const where: any = { organizationId };
    if (params.search) {
      where.OR = [
        { name: { contains: params.search, mode: 'insensitive' } },
        { code: { contains: params.search, mode: 'insensitive' } },
        { city: { contains: params.search, mode: 'insensitive' } },
        { region: { contains: params.search, mode: 'insensitive' } },
      ];
    }

    const [items, total] = await Promise.all([
      this.prisma.client.findMany({
        where,
        skip,
        take,
        orderBy,
        include: { _count: { select: { orders: true } } },
      }),
      this.prisma.client.count({ where }),
    ]);

    return { data: items, meta: buildPaginationMeta(total, page, limit) };
  }

  /** Org-scoped lookup — cross-tenant ID guessing impossible. */
  async findOne(organizationId: string, id: string) {
    const client = await this.prisma.client.findFirst({
      where: { id, organizationId },
      include: { _count: { select: { orders: true, rates: true } } },
    });
    if (!client) throw new NotFoundException('Client not found');
    return client;
  }

  async update(organizationId: string, id: string, dto: UpdateClientDto) {
    await this.findOne(organizationId, id);

    if (dto.code) {
      const existing = await this.prisma.client.findFirst({
        where: {
          organizationId,
          code: dto.code,
          NOT: { id },
        },
      });
      if (existing) {
        throw new ConflictException(`Another client with code "${dto.code}" already exists`);
      }
    }

    return this.prisma.client.update({
      where: { id },
      data: dto,
    });
  }

  async remove(organizationId: string, id: string) {
    const client = await this.findOne(organizationId, id);

    // Block delete if there are ANY orders. Order has ON DELETE RESTRICT
    // on its client FK so the cascade would fail anyway, but a clean
    // BadRequest with the count is far friendlier than a 500 leaking the
    // constraint name.
    const orderCount = client._count?.orders ?? 0;
    if (orderCount > 0) {
      throw new BadRequestException(
        `Client has ${orderCount} order${
          orderCount === 1 ? '' : 's'
        } in history. Reassign or archive them before deleting.`,
      );
    }

    await this.prisma.client.delete({ where: { id } });
    return { deleted: true };
  }

  async bulkUploadFromFile(organizationId: string, file: Express.Multer.File): Promise<BulkUploadResult> {
    const { rows, errors } = this.bulkUpload.parseFile(file.buffer, file.originalname, CLIENT_MAPPINGS);
    const created: Record<string, unknown>[] = [];
    let skipped = 0;

    for (const row of rows) {
      const code = String(row.code || '').trim().toUpperCase();
      if (!code || !/^[A-Z0-9_-]{2,20}$/.test(code)) {
        skipped++;
        continue;
      }

      // Per-org uniqueness check (was previously global — could collide
      // across tenants and silently skip)
      const existing = await this.prisma.client.findFirst({
        where: { organizationId, code },
      });
      if (existing) {
        skipped++;
        continue;
      }

      const client = await this.prisma.client.create({
        data: {
          organizationId,
          name: String(row.name || '').trim(),
          code,
          contactName: (row.contactName as string) || undefined,
          contactEmail: (row.contactEmail as string) || undefined,
          contactPhone: (row.contactPhone as string) || undefined,
          address: (row.address as string) || undefined,
          city: (row.city as string) || undefined,
          country: (row.country as string) || 'Kenya',
        },
      });
      created.push(client);
    }

    this.logger.log(`Bulk client upload: ${created.length} created, ${skipped} skipped, ${errors.length} errors`);
    return { created: created.length, skipped, errors, records: created, totalRows: rows.length };
  }
}

results matching ""

    No results matching ""