mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 14:17:38 +00:00
feat(warehouses): allow deleting a warehouse
Soft-delete route guarded by warehouses:delete, refused with 409 while yards remain — zones and inventory hang off a yard, so cascading would orphan stock. Backoffice list gets a delete action in both views, omitted when the user lacks the permission.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Backlog registration of full containers that were already sitting in a yard
|
||||
* before the system knew about them. Such a row carries a true, backdated
|
||||
* `arrived_at` for the record but accrues NO storage or demurrage — the
|
||||
* operator decided these are not billable retroactively — so the flag exists
|
||||
* to keep the fee engine off them.
|
||||
*
|
||||
* `company_id` / `company_name` carry the owner, since a backlog row has no
|
||||
* booking to inherit one from. The name is free text for a company that is not
|
||||
* a registered customer yet.
|
||||
*/
|
||||
export class WarehouseInventoryBacklogRegistration3800000000000 implements MigrationInterface {
|
||||
name = 'WarehouseInventoryBacklogRegistration3800000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS backlog_registration boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS company_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS company_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
DROP COLUMN IF EXISTS backlog_registration,
|
||||
DROP COLUMN IF EXISTS company_id,
|
||||
DROP COLUMN IF EXISTS company_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
/**
|
||||
* Deleting a warehouse that still holds yards would orphan every zone and the
|
||||
* inventory sitting in them, so remove() refuses instead of cascading.
|
||||
*/
|
||||
function makeService(warehouse: unknown) {
|
||||
const warehousesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(warehouse),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = Object.create(WarehousesService.prototype) as Record<string, unknown>;
|
||||
service.warehousesRepository = warehousesRepository;
|
||||
|
||||
return { service: service as unknown as WarehousesService, warehousesRepository };
|
||||
}
|
||||
|
||||
describe('WarehousesService.remove', () => {
|
||||
it('soft-deletes a warehouse with no yards', async () => {
|
||||
const { service, warehousesRepository } = makeService({ id: 'w1', code: 'GMP', yards: [] });
|
||||
|
||||
await expect(service.remove('w1')).resolves.toEqual({ id: 'w1', deleted: true });
|
||||
expect(warehousesRepository.softDelete).toHaveBeenCalledWith('w1');
|
||||
});
|
||||
|
||||
it('refuses while yards remain', async () => {
|
||||
const { service, warehousesRepository } = makeService({
|
||||
id: 'w1',
|
||||
code: 'GMP',
|
||||
yards: [{ id: 'y1' }],
|
||||
});
|
||||
|
||||
await expect(service.remove('w1')).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s on an unknown warehouse', async () => {
|
||||
const { service, warehousesRepository } = makeService(null);
|
||||
|
||||
await expect(service.remove('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(warehousesRepository.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A loaded container that was already sitting in a yard before the system knew
|
||||
* about it. It has no booking, so the owner is carried as a company reference
|
||||
* or free text, and `arrivedAt` is the true historical arrival rather than now.
|
||||
*/
|
||||
export class RegisterBacklogContainerDto {
|
||||
@ApiProperty({ description: 'ISO 6346 container number' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ description: 'True historical arrival date — drives nothing billable.' })
|
||||
@IsDateString()
|
||||
arrivedAt!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Registered customer, when the owner is one.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Owner name — free text when the company is not a customer yet.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Net weight in the unit the warehouse records (tonnes).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
/**
|
||||
* ponytail: defaults to 0 when unknown, which is the honest value for a box
|
||||
* nobody weighed. `containers.max_gross_weight` is a ceiling in
|
||||
* cargoes.service, so set real figures here before this box is ever used for
|
||||
* a new cargo assignment.
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxGrossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class BulkRegisterBacklogDto {
|
||||
@ApiProperty({ type: [RegisterBacklogContainerDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RegisterBacklogContainerDto)
|
||||
containers!: RegisterBacklogContainerDto[];
|
||||
}
|
||||
@@ -105,6 +105,22 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
/**
|
||||
* Registered as backlog: the box was already in the yard before the system
|
||||
* knew about it. `arrivedAt` is the true, backdated arrival, but no storage
|
||||
* or demurrage accrues — see WarehouseFeeService.previewForInventory.
|
||||
*/
|
||||
@Column({ name: 'backlog_registration', type: 'boolean', default: false })
|
||||
backlogRegistration!: boolean;
|
||||
|
||||
/** Owner of a row with no booking to inherit one from. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
/** Owner as text — a company that is not a registered customer yet. */
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
quantity!: number;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
interface ItemAttributes {
|
||||
arrivedAt: Date | null;
|
||||
/** Backlog-registered box: real arrival on the record, but never billed. */
|
||||
backlogRegistration: boolean;
|
||||
gateClearedAt: Date | null;
|
||||
releaseDate: Date | null;
|
||||
freightType: string | null;
|
||||
@@ -260,6 +262,7 @@ export class WarehouseFeeService {
|
||||
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.backlog_registration AS "backlogRegistration",
|
||||
inv.gate_cleared_at AS "gateClearedAt",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.quantity AS "inventoryQuantity",
|
||||
@@ -777,6 +780,13 @@ export class WarehouseFeeService {
|
||||
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
const item = await this.loadItem(inventoryId);
|
||||
|
||||
// A backlog registration carries a backdated arrival so the record is
|
||||
// honest about how long the box has sat, but it was never booked through
|
||||
// EDR and is not billed for that history. No rule applies, so no preview —
|
||||
// which also keeps it off the invoice, since invoicing reads this same list.
|
||||
if (item.backlogRegistration) return [];
|
||||
|
||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||
const now = new Date();
|
||||
|
||||
@@ -894,6 +904,8 @@ export class WarehouseFeeService {
|
||||
trucks.map(async (t) => {
|
||||
const item: ItemAttributes = {
|
||||
arrivedAt: null,
|
||||
// Truck detention is a per-truck charge, never a warehouse backlog row.
|
||||
backlogRegistration: false,
|
||||
gateClearedAt: null,
|
||||
releaseDate: null,
|
||||
freightType: leg.freightType ?? null,
|
||||
|
||||
@@ -16,6 +16,10 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import {
|
||||
BulkRegisterBacklogDto,
|
||||
RegisterBacklogContainerDto,
|
||||
} from './dto/register-backlog.dto';
|
||||
import { ApproveDeliveryDto } from './dto/approve-delivery.dto';
|
||||
import { SetDoubleHandlingDto } from './dto/double-handling.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
@@ -143,6 +147,25 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.eligibleBookings(dir);
|
||||
}
|
||||
|
||||
@Post('register-backlog')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({
|
||||
summary: 'Register one loaded container already in the yard but never entered in the system',
|
||||
})
|
||||
registerBacklog(@Body() dto: RegisterBacklogContainerDto, @CurrentUser() user: TCurrentUser) {
|
||||
dto.performedBy = actorLabel(user) ?? dto.performedBy;
|
||||
return this.inventoryService.registerBacklogContainer(dto);
|
||||
}
|
||||
|
||||
@Post('register-backlog-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-register loaded containers already in the yard (Excel backlog)' })
|
||||
registerBacklogBulk(@Body() dto: BulkRegisterBacklogDto, @CurrentUser() user: TCurrentUser) {
|
||||
const performedBy = actorLabel(user);
|
||||
dto.containers.forEach((c) => (c.performedBy = performedBy ?? c.performedBy));
|
||||
return this.inventoryService.bulkRegisterBacklogContainers(dto);
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
|
||||
@@ -50,6 +50,10 @@ import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import {
|
||||
BulkRegisterBacklogDto,
|
||||
RegisterBacklogContainerDto,
|
||||
} from './dto/register-backlog.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
@@ -2863,6 +2867,136 @@ export class WarehouseInventoryService {
|
||||
await this.lastMileService.acceptBooking(booking.reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a loaded container that is already physically in a yard but was
|
||||
* never entered in the system. Unlike receive(), there is no booking, no
|
||||
* truck entrance to record (nobody remembers the driver of a box that has sat
|
||||
* for months) and the arrival is backdated to when it actually turned up.
|
||||
*
|
||||
* The row is flagged `backlogRegistration`, which keeps the fee engine off it
|
||||
* entirely — see WarehouseFeeService.previewForInventory. Capacity is still
|
||||
* charged, because the box does occupy the yard.
|
||||
*/
|
||||
async registerBacklogContainer(dto: RegisterBacklogContainerDto): Promise<WarehouseInventory> {
|
||||
const id = await this.dataSource.transaction((manager) => this.saveBacklogContainer(manager, dto));
|
||||
const saved = await this.inventoryRepository.findById(id);
|
||||
if (!saved) throw new NotFoundException(`Inventory ${id} not found after registration`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** The write itself, so single and bulk share one transaction each. */
|
||||
private async saveBacklogContainer(
|
||||
manager: EntityManager,
|
||||
dto: RegisterBacklogContainerDto,
|
||||
): Promise<string> {
|
||||
const containerNumber = dto.containerNumber.trim().toUpperCase();
|
||||
const arrivedAt = new Date(dto.arrivedAt);
|
||||
if (Number.isNaN(arrivedAt.getTime())) {
|
||||
throw new BadRequestException(`Arrival date "${dto.arrivedAt}" is not a valid date`);
|
||||
}
|
||||
if (arrivedAt.getTime() > Date.now()) {
|
||||
throw new BadRequestException('Arrival date cannot be in the future');
|
||||
}
|
||||
|
||||
{
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
const containerType = await manager.query(
|
||||
`SELECT id FROM freight.container_types WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[dto.containerTypeId],
|
||||
);
|
||||
if (containerType.length === 0) {
|
||||
throw new NotFoundException(`Container type ${dto.containerTypeId} not found`);
|
||||
}
|
||||
|
||||
// container_number is UNIQUE — reuse the existing record rather than
|
||||
// colliding, so a box seen before keeps one identity.
|
||||
const containers = manager.getRepository(Container);
|
||||
let container = await containers.findOne({ where: { containerNumber } });
|
||||
if (container) {
|
||||
const alreadyHeld = await manager.getRepository(WarehouseInventory).findOne({
|
||||
where: { containerId: container.id, status: In(['RECEIVED', 'STORED', 'READY_FOR_PICKUP']) },
|
||||
});
|
||||
if (alreadyHeld) {
|
||||
throw new BadRequestException(
|
||||
`Container ${containerNumber} is already in the warehouse (status ${alreadyHeld.status})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
container = await containers.save(
|
||||
containers.create({
|
||||
containerNumber,
|
||||
containerTypeId: dto.containerTypeId,
|
||||
sealNumber: dto.sealNumber?.trim() || null,
|
||||
tareWeight: dto.tareWeight ?? 0,
|
||||
maxGrossWeight: dto.maxGrossWeight ?? 0,
|
||||
status: 'LOADED',
|
||||
bookingId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, 1);
|
||||
this.assertCapacity('Yard', yard, weight, volume, 1);
|
||||
this.assertCapacity('Zone', zone, weight, volume, 1);
|
||||
|
||||
const owner = dto.companyName?.trim() || null;
|
||||
const grnNumber = this.generateGrnNumber('WH', 'BACKLOG', arrivedAt, owner);
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId: null,
|
||||
containerId: container.id,
|
||||
companyId: dto.companyId ?? null,
|
||||
companyName: owner,
|
||||
quantity: 1,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
grnNumber,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
backlogRegistration: true,
|
||||
notes: this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes:
|
||||
dto.notes?.trim() ||
|
||||
`Backlog registration — already in yard, arrived ${arrivedAt.toISOString().slice(0, 10)}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyCapacityDelta(manager, dto, weight, volume, 1);
|
||||
return saved.id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk backlog registration. All-or-nothing: one bad row rejects the sheet,
|
||||
* so a half-registered yard can never happen.
|
||||
*/
|
||||
async bulkRegisterBacklogContainers(dto: BulkRegisterBacklogDto): Promise<WarehouseInventory[]> {
|
||||
const numbers = dto.containers.map((c) => c.containerNumber.trim().toUpperCase());
|
||||
const seen = new Set<string>();
|
||||
const repeated = [...new Set(numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))))];
|
||||
if (repeated.length > 0) {
|
||||
throw new BadRequestException(`Container number(s) repeated in the upload: ${repeated.join(', ')}`);
|
||||
}
|
||||
|
||||
const ids = await this.dataSource.transaction(async (manager) => {
|
||||
const written: string[] = [];
|
||||
for (const container of dto.containers) {
|
||||
written.push(await this.saveBacklogContainer(manager, container));
|
||||
}
|
||||
return written;
|
||||
});
|
||||
|
||||
return this.inventoryRepository.findAll({ where: { id: In(ids) } });
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
@@ -25,6 +25,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
FREIGHT_PERMS.warehouseDashboard.view,
|
||||
FREIGHT_PERMS.warehouses.create,
|
||||
FREIGHT_PERMS.warehouses.update,
|
||||
FREIGHT_PERMS.warehouses.delete,
|
||||
FREIGHT_PERMS.warehouseYards.view,
|
||||
FREIGHT_PERMS.warehouseYards.create,
|
||||
])
|
||||
@@ -79,6 +80,16 @@ export class WarehousesController {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouses.delete)
|
||||
@ApiOperation({
|
||||
summary: 'Delete warehouse',
|
||||
description: 'Soft-deletes the warehouse. Refused while it still has yards.',
|
||||
})
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.warehousesService.remove(id);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
|
||||
@@ -108,6 +108,25 @@ export class WarehousesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a warehouse. Yards (and therefore zones and inventory, which
|
||||
* hang off a zone) are left alone — a warehouse holding them is refused
|
||||
* rather than silently orphaning stock.
|
||||
*/
|
||||
async remove(id: string): Promise<{ id: string; deleted: true }> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (existing.yards?.length) {
|
||||
throw new ConflictException(
|
||||
`Warehouse ${existing.code} still has ${existing.yards.length} yard(s). Delete them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.warehousesRepository.softDelete(id);
|
||||
|
||||
return { id, deleted: true };
|
||||
}
|
||||
|
||||
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||
private mapDbError(error: unknown): never {
|
||||
if (error instanceof QueryFailedError) {
|
||||
|
||||
Reference in New Issue
Block a user