File

src/lanes/lanes.service.ts

Index

Methods

Constructor

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

Methods

Async addTariff
addTariff(organizationId: string, laneId: string, dto: CreateTariffDto)
Parameters :
Name Type Optional
organizationId string No
laneId string No
dto CreateTariffDto 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: CreateLaneDto)
Parameters :
Name Type Optional
organizationId string No
dto CreateLaneDto 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. Returns the lane plus its tariffs decorated with isActive.

Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async getTariffs
getTariffs(organizationId: string, laneId: string, opts?: literal type)
Parameters :
Name Type Optional
organizationId string No
laneId string No
opts literal type Yes
Returns : unknown
Async remove
remove(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async removeTariff
removeTariff(organizationId: string, laneId: string, tariffId: string)
Parameters :
Name Type Optional
organizationId string No
laneId string No
tariffId string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateLaneDto)
Parameters :
Name Type Optional
organizationId string No
id string No
dto UpdateLaneDto No
Returns : unknown
Async updateTariff
updateTariff(organizationId: string, laneId: string, tariffId: string, dto: Partial<CreateTariffDto>)
Parameters :
Name Type Optional
organizationId string No
laneId string No
tariffId string No
dto Partial<CreateTariffDto> No
Returns : unknown
import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateLaneDto } from './dto/create-lane.dto';
import { UpdateLaneDto } from './dto/update-lane.dto';
import { CreateTariffDto } from './dto/create-tariff.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
  PaginationParams,
} from '../common/utils/pagination.util';
import { BulkUploadService, LANE_MAPPINGS, BulkUploadResult } from '../common/services/bulk-upload.service';

/** Decorate a tariff row with an `isActive` flag computed from its date window. */
function withActive<T extends { effectiveFrom: Date; effectiveTo: Date | null }>(
  tariff: T,
  now: Date = new Date(),
): T & { isActive: boolean } {
  const startsBy = new Date(tariff.effectiveFrom).getTime() <= now.getTime();
  const stillRunning =
    !tariff.effectiveTo || new Date(tariff.effectiveTo).getTime() >= now.getTime();
  return { ...tariff, isActive: startsBy && stillRunning };
}

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

  constructor(private prisma: PrismaService) {}

  async create(organizationId: string, dto: CreateLaneDto) {
    return this.prisma.lane.create({
      data: {
        organizationId,
        name: dto.name,
        originName: dto.originName,
        originZoneId: dto.originZoneId,
        destName: dto.destName,
        destZoneId: dto.destZoneId,
        distanceKm: dto.distanceKm,
        estimatedHours: dto.estimatedHours,
      },
    });
  }

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

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

    // Order has a `laneId` field but Lane has no reverse relation, so the
    // _count.orders trick doesn't work. Fetch counts in one grouped query
    // and stitch them in — keeps the response shape uniform with trips.
    const laneIds = items.map((l) => l.id);
    const orderCounts =
      laneIds.length > 0
        ? await this.prisma.order.groupBy({
            by: ['laneId'],
            where: { organizationId, laneId: { in: laneIds } },
            _count: true,
          })
        : [];
    const orderCountMap = new Map(orderCounts.map((r) => [r.laneId, r._count]));
    const enriched = items.map((l) => ({
      ...l,
      _count: { ...l._count, orders: orderCountMap.get(l.id) ?? 0 },
    }));

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

  /** Org-scoped lookup. Returns the lane plus its tariffs decorated with `isActive`. */
  async findOne(organizationId: string, id: string) {
    const lane = await this.prisma.lane.findFirst({
      where: { id, organizationId },
      include: {
        tariffs: { orderBy: { effectiveFrom: 'desc' } },
        _count: { select: { trips: true } },
      },
    });
    if (!lane) throw new NotFoundException('Lane not found');
    const orderCount = await this.prisma.order.count({
      where: { organizationId, laneId: id },
    });
    const now = new Date();
    return {
      ...lane,
      tariffs: lane.tariffs.map((t) => withActive(t, now)),
      _count: { ...lane._count, orders: orderCount },
    };
  }

  async update(organizationId: string, id: string, dto: UpdateLaneDto) {
    await this.findOne(organizationId, id);
    return this.prisma.lane.update({
      where: { id },
      data: dto,
    });
  }

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

    // Block delete if any trip or order still references this lane.
    // Tariffs are owned by the lane and get deleted with it; trips/orders
    // are operational data we must not orphan.
    const tripCount = lane._count?.trips ?? 0;
    const orderCount = lane._count?.orders ?? 0;
    if (tripCount > 0 || orderCount > 0) {
      throw new BadRequestException(
        `Lane is in use by ${tripCount} trip${tripCount === 1 ? '' : 's'} and ${orderCount} order${
          orderCount === 1 ? '' : 's'
        }. Re-route or close these first.`,
      );
    }

    await this.prisma.tariff.deleteMany({ where: { laneId: id } });
    await this.prisma.lane.delete({ where: { id } });
    return { deleted: true };
  }

  // ── Tariff management ──────────────────────────────────────────────

  /**
   * Cross-field validation for tariff date window + at-least-one-rate.
   * Centralized here so create AND update both enforce it.
   */
  private validateTariffPayload(dto: CreateTariffDto, mode: 'create' | 'update') {
    const hasAnyRate =
      (dto.ratePerTrip ?? null) !== null ||
      (dto.ratePerKm ?? null) !== null ||
      (dto.ratePerTon ?? null) !== null;
    if (mode === 'create' && !hasAnyRate) {
      throw new BadRequestException(
        'A tariff needs at least one of ratePerTrip, ratePerKm, or ratePerTon.',
      );
    }
    if (dto.effectiveFrom && dto.effectiveTo) {
      const from = new Date(dto.effectiveFrom).getTime();
      const to = new Date(dto.effectiveTo).getTime();
      if (Number.isNaN(from) || Number.isNaN(to)) {
        throw new BadRequestException('effectiveFrom and effectiveTo must be valid dates');
      }
      if (to <= from) {
        throw new BadRequestException('effectiveTo must be strictly after effectiveFrom');
      }
    }
  }

  async addTariff(organizationId: string, laneId: string, dto: CreateTariffDto) {
    await this.findOne(organizationId, laneId);
    this.validateTariffPayload(dto, 'create');

    return this.prisma.tariff.create({
      data: {
        laneId,
        vehicleType: dto.vehicleType,
        ratePerTrip: dto.ratePerTrip,
        ratePerKm: dto.ratePerKm,
        ratePerTon: dto.ratePerTon,
        minCharge: dto.minCharge,
        currency: (dto.currency || 'KES').toUpperCase(),
        effectiveFrom: new Date(dto.effectiveFrom),
        effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
      },
    });
  }

  async getTariffs(organizationId: string, laneId: string, opts?: { activeOnly?: boolean }) {
    await this.findOne(organizationId, laneId);
    const tariffs = await this.prisma.tariff.findMany({
      where: { laneId },
      orderBy: { effectiveFrom: 'desc' },
    });
    const now = new Date();
    const decorated = tariffs.map((t) => withActive(t, now));
    if (opts?.activeOnly) return decorated.filter((t) => t.isActive);
    return decorated;
  }

  /**
   * Locate a specific tariff and confirm it belongs to a lane in the
   * caller's org. Returns the row, or throws 404.
   */
  private async findTariff(organizationId: string, laneId: string, tariffId: string) {
    await this.findOne(organizationId, laneId); // ensures lane is in org
    const tariff = await this.prisma.tariff.findFirst({
      where: { id: tariffId, laneId },
    });
    if (!tariff) throw new NotFoundException('Tariff not found');
    return tariff;
  }

  async updateTariff(
    organizationId: string,
    laneId: string,
    tariffId: string,
    dto: Partial<CreateTariffDto>,
  ) {
    const existing = await this.findTariff(organizationId, laneId, tariffId);

    // Merge for cross-field validation — effectiveFrom may not be in the
    // partial payload but we still need to compare against effectiveTo.
    this.validateTariffPayload(
      {
        ...existing,
        ...dto,
        effectiveFrom: dto.effectiveFrom || existing.effectiveFrom.toISOString(),
        effectiveTo:
          dto.effectiveTo !== undefined
            ? dto.effectiveTo
            : existing.effectiveTo?.toISOString(),
      } as CreateTariffDto,
      'update',
    );

    return this.prisma.tariff.update({
      where: { id: tariffId },
      data: {
        vehicleType: dto.vehicleType,
        ratePerTrip: dto.ratePerTrip,
        ratePerKm: dto.ratePerKm,
        ratePerTon: dto.ratePerTon,
        minCharge: dto.minCharge,
        currency: dto.currency ? dto.currency.toUpperCase() : undefined,
        effectiveFrom: dto.effectiveFrom ? new Date(dto.effectiveFrom) : undefined,
        effectiveTo:
          dto.effectiveTo !== undefined
            ? dto.effectiveTo
              ? new Date(dto.effectiveTo)
              : null
            : undefined,
      },
    });
  }

  async removeTariff(organizationId: string, laneId: string, tariffId: string) {
    await this.findTariff(organizationId, laneId, tariffId);
    await this.prisma.tariff.delete({ where: { id: tariffId } });
    return { deleted: true };
  }

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

    for (const row of rows) {
      const name = String(row.name || '').trim();
      const distanceKm = Number(row.distanceKm) || 0;
      // Skip rows that would fail the same DTO validation we apply to
      // single-record creates — keeps bulk and form paths consistent.
      if (!name || distanceKm <= 0 || distanceKm > 20000) {
        skipped++;
        continue;
      }

      const existing = await this.prisma.lane.findFirst({
        where: { organizationId, name: { equals: name, mode: 'insensitive' } },
      });
      if (existing) {
        skipped++;
        continue;
      }

      const lane = await this.prisma.lane.create({
        data: {
          organizationId,
          name,
          originName: String(row.originName || '').trim(),
          destName: String(row.destName || '').trim(),
          distanceKm,
          estimatedHours: (row.estimatedHours as number) || undefined,
        },
      });
      created.push(lane);
    }

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