src/zones/zones.service.ts
Methods |
|
constructor(prisma: PrismaService)
|
||||||
|
Defined in src/zones/zones.service.ts:21
|
||||||
|
Parameters :
|
| Async assignDistributor |
assignDistributor(organizationId: string, zoneId: string, clientId: string)
|
|
Defined in src/zones/zones.service.ts:169
|
|
Returns :
unknown
|
| Async bulkUploadFromFile | |||||||||
bulkUploadFromFile(organizationId: string, file: Express.Multer.File)
|
|||||||||
|
Defined in src/zones/zones.service.ts:199
|
|||||||||
|
Parameters :
Returns :
Promise<BulkUploadResult>
|
| Async create | |||||||||
create(organizationId: string, dto: CreateZoneDto)
|
|||||||||
|
Defined in src/zones/zones.service.ts:25
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findAll | |||||||||
findAll(organizationId: string, params: PaginationParams)
|
|||||||||
|
Defined in src/zones/zones.service.ts:46
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/zones/zones.service.ts:75
|
|
Org-scoped lookup. findFirst with organizationId in the where clause makes cross-tenant ID-guessing physically impossible.
Returns :
unknown
|
| Async getDistributors |
getDistributors(organizationId: string, zoneId: string)
|
|
Defined in src/zones/zones.service.ts:153
|
|
Returns :
unknown
|
| Async getZoneMappings | ||||||
getZoneMappings(organizationId: string)
|
||||||
|
Defined in src/zones/zones.service.ts:135
|
||||||
|
Parameters :
Returns :
unknown
|
| Async remove |
remove(organizationId: string, id: string)
|
|
Defined in src/zones/zones.service.ts:106
|
|
Returns :
unknown
|
| Async unassignDistributor |
unassignDistributor(organizationId: string, zoneId: string, clientId: string)
|
|
Defined in src/zones/zones.service.ts:185
|
|
Returns :
unknown
|
| Async update | ||||||||||||
update(organizationId: string, id: string, dto: UpdateZoneDto)
|
||||||||||||
|
Defined in src/zones/zones.service.ts:83
|
||||||||||||
|
Parameters :
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 };
}
}