src/lanes/lanes.service.ts
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/lanes/lanes.service.ts:32
|
||||||
|
Parameters :
|
| Async addTariff | ||||||||||||
addTariff(organizationId: string, laneId: string, dto: CreateTariffDto)
|
||||||||||||
|
Defined in src/lanes/lanes.service.ts:175
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async bulkUploadFromFile | |||||||||
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
|
|||||||||
|
Defined in src/lanes/lanes.service.ts:268
|
|||||||||
|
Parameters :
Returns :
Promise<BulkUploadResult>
|
| Async create | |||||||||
create(organizationId: string, dto: CreateLaneDto)
|
|||||||||
|
Defined in src/lanes/lanes.service.ts:36
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findAll | |||||||||
findAll(organizationId: string, params: PaginationParams)
|
|||||||||
|
Defined in src/lanes/lanes.service.ts:51
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/lanes/lanes.service.ts:98
|
|
Org-scoped lookup. Returns the lane plus its tariffs decorated with
Returns :
unknown
|
| Async getTariffs |
getTariffs(organizationId: string, laneId: string, opts?: literal type)
|
|
Defined in src/lanes/lanes.service.ts:194
|
|
Returns :
unknown
|
| Async remove |
remove(organizationId: string, id: string)
|
|
Defined in src/lanes/lanes.service.ts:126
|
|
Returns :
unknown
|
| Async removeTariff |
removeTariff(organizationId: string, laneId: string, tariffId: string)
|
|
Defined in src/lanes/lanes.service.ts:262
|
|
Returns :
unknown
|
| Async update | ||||||||||||
update(organizationId: string, id: string, dto: UpdateLaneDto)
|
||||||||||||
|
Defined in src/lanes/lanes.service.ts:118
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async updateTariff | |||||||||||||||
updateTariff(organizationId: string, laneId: string, tariffId: string, dto: Partial<CreateTariffDto>)
|
|||||||||||||||
|
Defined in src/lanes/lanes.service.ts:219
|
|||||||||||||||
|
Parameters :
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 };
}
}