File

src/zones/zones.service.ts

Index

Methods

Constructor

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

Methods

Async assignDistributor
assignDistributor(organizationId: string, zoneId: string, clientId: string)
Parameters :
Name Type Optional
organizationId string No
zoneId string No
clientId string No
Returns : unknown
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: CreateZoneDto)
Parameters :
Name Type Optional
organizationId string No
dto CreateZoneDto 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. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async getDistributors
getDistributors(organizationId: string, zoneId: string)
Parameters :
Name Type Optional
organizationId string No
zoneId string No
Returns : unknown
Async getZoneMappings
getZoneMappings(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async remove
remove(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async unassignDistributor
unassignDistributor(organizationId: string, zoneId: string, clientId: string)
Parameters :
Name Type Optional
organizationId string No
zoneId string No
clientId string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateZoneDto)
Parameters :
Name Type Optional
organizationId string No
id string No
dto UpdateZoneDto No
Returns : unknown
import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateZoneDto } from './dto/create-zone.dto';
import { UpdateZoneDto } from './dto/update-zone.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
  PaginationParams,
} from '../common/utils/pagination.util';
import { BulkUploadService, ZONE_MAPPINGS, BulkUploadResult } from '../common/services/bulk-upload.service';

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

  constructor(private prisma: PrismaService) {}

  async create(organizationId: string, dto: CreateZoneDto) {
    // Code is uppercased by the DTO transform; check uniqueness within
    // the org so admins don't end up with two zones called "NBO".
    const existing = await this.prisma.zone.findFirst({
      where: { organizationId, code: dto.code },
    });
    if (existing) {
      throw new ConflictException(`A zone with code "${dto.code}" already exists in this organization`);
    }

    return this.prisma.zone.create({
      data: {
        organizationId,
        name: dto.name,
        code: dto.code,
        description: dto.description,
        region: dto.region,
      },
    });
  }

  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' } },
        { region: { contains: params.search, mode: 'insensitive' } },
      ];
    }

    const [items, total] = await Promise.all([
      this.prisma.zone.findMany({
        where,
        skip,
        take,
        orderBy,
      }),
      this.prisma.zone.count({ where }),
    ]);

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

  /**
   * Org-scoped lookup. findFirst with organizationId in the where clause
   * makes cross-tenant ID-guessing physically impossible.
   */
  async findOne(organizationId: string, id: string) {
    const zone = await this.prisma.zone.findFirst({
      where: { id, organizationId },
    });
    if (!zone) throw new NotFoundException('Zone not found');
    return zone;
  }

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

    // If the code is being changed, re-check uniqueness within the org.
    if (dto.code) {
      const existing = await this.prisma.zone.findFirst({
        where: {
          organizationId,
          code: dto.code,
          NOT: { id },
        },
      });
      if (existing) {
        throw new ConflictException(`Another zone with code "${dto.code}" already exists`);
      }
    }

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

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

    // Block delete if zone is in use as origin or destination on a lane,
    // or if any client is currently mapped to it. Forces the admin to
    // re-assign before deleting — prevents orphaned references that the
    // optimizer / ETA code would silently fall back to "Unknown route".
    const [laneRefs, clientRefs] = await Promise.all([
      this.prisma.lane.count({
        where: {
          organizationId,
          OR: [{ originZoneId: id }, { destZoneId: id }],
        },
      }),
      this.prisma.client.count({ where: { organizationId, zoneId: id } }),
    ]);

    if (laneRefs > 0 || clientRefs > 0) {
      throw new BadRequestException(
        `Zone is in use by ${laneRefs} lane${laneRefs === 1 ? '' : 's'} and ${clientRefs} client${
          clientRefs === 1 ? '' : 's'
        }. Re-assign or remove these first.`,
      );
    }

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

  async getZoneMappings(organizationId: string) {
    const zones = await this.prisma.zone.findMany({
      where: { organizationId },
      orderBy: { name: 'asc' },
    });

    const clients = await this.prisma.client.findMany({
      where: { organizationId, zoneId: { not: null } },
      select: { id: true, name: true, code: true, zoneId: true, city: true },
    });

    return zones.map((zone) => ({
      ...zone,
      distributors: clients.filter((c) => c.zoneId === zone.id),
      distributorCount: clients.filter((c) => c.zoneId === zone.id).length,
    }));
  }

  async getDistributors(organizationId: string, zoneId: string) {
    await this.findOne(organizationId, zoneId);
    return this.prisma.client.findMany({
      where: { organizationId, zoneId },
      select: {
        id: true,
        name: true,
        code: true,
        contactName: true,
        contactEmail: true,
        city: true,
        isActive: true,
      },
    });
  }

  async assignDistributor(organizationId: string, zoneId: string, clientId: string) {
    await this.findOne(organizationId, zoneId);
    // Verify the client also belongs to this org BEFORE updating —
    // otherwise an attacker could re-parent a client from another org.
    const client = await this.prisma.client.findFirst({
      where: { id: clientId, organizationId },
    });
    if (!client) {
      throw new NotFoundException('Client not found in this organization');
    }
    return this.prisma.client.update({
      where: { id: clientId },
      data: { zoneId },
    });
  }

  async unassignDistributor(organizationId: string, zoneId: string, clientId: string) {
    await this.findOne(organizationId, zoneId);
    const client = await this.prisma.client.findFirst({
      where: { id: clientId, organizationId, zoneId },
    });
    if (!client) {
      throw new NotFoundException('Client is not assigned to this zone');
    }
    return this.prisma.client.update({
      where: { id: clientId },
      data: { zoneId: null },
    });
  }

  async bulkUploadFromFile(organizationId: string, file: Express.Multer.File): Promise<BulkUploadResult> {
    const { rows, errors } = this.bulkUpload.parseFile(file.buffer, file.originalname, ZONE_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,12}$/.test(code)) {
        skipped++;
        continue;
      }

      const existing = await this.prisma.zone.findFirst({
        where: { organizationId, code },
      });
      if (existing) {
        skipped++;
        continue;
      }

      const zone = await this.prisma.zone.create({
        data: {
          organizationId,
          name: String(row.name || '').trim(),
          code,
          description: (row.description as string) || undefined,
          region: (row.region as string) || undefined,
        },
      });
      created.push(zone);
    }

    this.logger.log(`Bulk zone 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 ""