add service-types and cargo-types CRUD modules with pagination, filtering

This commit is contained in:
marshal
2026-05-28 11:34:24 +03:00
parent 6d49c38f48
commit d08bc6d3fe
22 changed files with 1211 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CargoTypesService } from "./cargo-types.service";
import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto";
import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto";
import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto";
@ApiTags("cargo-types")
@Controller("cargo-types")
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@ApiOperation({
summary: "List cargo types",
description: "Paginated list with optional filtering by isActive, requiresDirectorApproval, parentGroupId, and name search.",
})
findAll(@Query() filter: FilterCargoTypeDto) {
return this.service.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a cargo type by ID", description: "Returns the cargo type with parent and children relations." })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: "Create a new cargo type" })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(":id")
@ApiOperation({ summary: "Update a cargo type" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCargoTypeDto,
) {
return this.service.update(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Soft-delete a cargo type" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CargoType } from "./entities/cargo-type.entity";
import { CARGO_TYPES_REPOSITORY } from "./interfaces/cargo-types.repository.interface";
import { CargoTypesRepository } from "./cargo-types.repository";
import { CargoTypesController } from "./cargo-types.controller";
import { CargoTypesService } from "./cargo-types.service";
@Module({
imports: [TypeOrmModule.forFeature([CargoType])],
controllers: [CargoTypesController],
providers: [
CargoTypesRepository,
{
provide: CARGO_TYPES_REPOSITORY,
useExisting: CargoTypesRepository,
},
CargoTypesService,
],
exports: [CargoTypesService],
})
export class CargoTypesModule {}

View File

@@ -0,0 +1,31 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindManyOptions, Repository } from "typeorm";
import { CargoType } from "./entities/cargo-type.entity";
import { ICargoTypesRepository } from "./interfaces/cargo-types.repository.interface";
@Injectable()
export class CargoTypesRepository
extends BaseRepository<CargoType>
implements ICargoTypesRepository
{
constructor(
@InjectRepository(CargoType)
repository: Repository<CargoType>,
) {
super(repository);
}
override findById(id: string): Promise<CargoType | null> {
return this.repository.findOne({
where: { id },
relations: { parent: true, children: true },
});
}
override findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]> {
return this.repository.findAndCount(options);
}
}

View File

@@ -0,0 +1,135 @@
import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ILike } from "typeorm";
import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto";
import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto";
import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto";
import { CargoType } from "./entities/cargo-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from "./interfaces/cargo-types.repository.interface";
@Injectable()
export class CargoTypesService {
constructor(
@Inject(CARGO_TYPES_REPOSITORY)
private readonly repository: ICargoTypesRepository,
) {}
/**
* Find all cargo types with pagination and optional filtering.
*/
async findAll(filter: FilterCargoTypeDto): Promise<{
data: CargoType[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}> {
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
}
if (filter.parentGroupId !== undefined) {
where.parentGroupId = filter.parentGroupId;
}
if (filter.search) {
where.cargoTypeName = ILike(`%${filter.search}%`);
}
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy!]: filter.sortOrder },
skip: (filter.page! - 1) * filter.pageSize!,
take: filter.pageSize,
relations: { parent: true },
});
return {
data,
meta: {
total,
page: filter.page!,
pageSize: filter.pageSize!,
totalPages: Math.ceil(total / filter.pageSize!),
},
};
}
/**
* Get a single cargo type by ID.
*/
async findById(id: string): Promise<CargoType> {
const entity = await this.repository.findById(id);
if (!entity) {
throw new NotFoundException(`Cargo type ${id} not found`);
}
return entity;
}
/**
* Create a new cargo type.
*/
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
// Validate parent exists if provided
if (dto.parentGroupId) {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) {
throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
}
return this.repository.create({
cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/**
* Update an existing cargo type.
*/
async update(id: string, dto: UpdateCargoTypeDto): Promise<CargoType> {
await this.findById(id);
// Validate parent exists if provided
const parentGroupId = dto.parentGroupId;
if (parentGroupId) {
const parent = await this.repository.findById(parentGroupId);
if (!parent) {
throw new NotFoundException(`Parent cargo type ${parentGroupId} not found`);
}
// Prevent circular reference
if (parentGroupId === id) {
throw new ConflictException("A cargo type cannot be its own parent");
}
}
const updated = await this.repository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Cargo type ${id} not found`);
}
return this.findById(id);
}
/**
* Soft-delete a cargo type.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator";
export class CreateCargoTypeDto {
@ApiProperty({ description: "Cargo type name", maxLength: 255 })
@IsString()
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({ description: "Parent cargo type group ID (UUID) for hierarchical structure" })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ description: "Show free text box for this cargo type", default: false })
@IsOptional()
@IsBoolean()
showFreeTextBox?: boolean = false;
@ApiPropertyOptional({ description: "Requires director approval for this cargo type", default: false })
@IsOptional()
@IsBoolean()
requiresDirectorApproval?: boolean = false;
@ApiPropertyOptional({ description: "Is the cargo type active", default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean = true;
@ApiPropertyOptional({ description: "Display order for UI", default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number = 1;
}

View File

@@ -0,0 +1,51 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class FilterCargoTypeDto {
@ApiPropertyOptional({ description: "Filter by active status" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isActive?: boolean;
@ApiPropertyOptional({ description: "Filter by requires director approval" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ description: "Filter by parent group ID (or 'root' for top-level only)" })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ description: "Search by cargo type name (case-insensitive)" })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ["cargoTypeName", "displayOrder", "createdAt"], default: "displayOrder" })
@IsOptional()
@IsIn(["cargoTypeName", "displayOrder", "createdAt"])
sortBy?: string = "displayOrder";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "ASC";
@ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateCargoTypeDto } from "./create-cargo-type.dto";
export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from "typeorm";
@Entity({ schema: "freight", name: "cargo_types" })
@Index(["is_active"])
@Index(["display_order"])
@Index(["parent_group_id"])
export class CargoType extends BaseEntity {
@Column({ name: "cargo_type_name", type: "varchar", length: 255, nullable: false })
cargoTypeName!: string;
@Column({ name: "parent_group_id", type: "uuid", nullable: true })
parentGroupId?: string | null;
@Column({ name: "show_free_text_box", type: "boolean", default: false })
showFreeTextBox!: boolean;
@Column({ name: "requires_director_approval", type: "boolean", default: false })
requiresDirectorApproval!: boolean;
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
@Column({ name: "display_order", type: "int", default: 1 })
displayOrder!: number;
@ManyToOne(() => CargoType, (cargoType) => cargoType.children, {
nullable: true,
onDelete: "SET NULL",
})
@JoinColumn({ name: "parent_group_id" })
parent?: CargoType | null;
@OneToMany(() => CargoType, (cargoType) => cargoType.parent)
children?: CargoType[];
}

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from "typeorm";
import { CargoType } from "../entities/cargo-type.entity";
export interface ICargoTypesRepository {
findById(id: string): Promise<CargoType | null>;
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
create(data: Partial<CargoType>): Promise<CargoType>;
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
softDelete(id: string): Promise<void>;
}
export const CARGO_TYPES_REPOSITORY = Symbol("CARGO_TYPES_REPOSITORY");

View File

@@ -0,0 +1,51 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateServiceTypeDto {
@ApiProperty({ description: "Service type name", maxLength: 255 })
@IsString()
@MaxLength(255)
serviceName!: string;
@ApiPropertyOptional({ description: "Detailed description of the service" })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: "Can be booked alone", default: true })
@IsOptional()
@IsBoolean()
canBeBookedAlone?: boolean = true;
@ApiPropertyOptional({ description: "Includes first mile service", default: false })
@IsOptional()
@IsBoolean()
includesFirstMile?: boolean = false;
@ApiPropertyOptional({ description: "Includes last mile service", default: false })
@IsOptional()
@IsBoolean()
includesLastMile?: boolean = false;
@ApiPropertyOptional({ description: "Includes customs clearance", default: false })
@IsOptional()
@IsBoolean()
includesCustoms?: boolean = false;
@ApiPropertyOptional({ description: "Priority bonus points for booking priority", default: 0 })
@IsOptional()
@IsInt()
@Min(0)
priorityBonusPoints?: number = 0;
@ApiPropertyOptional({ description: "Is the service type active", default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean = true;
@ApiPropertyOptional({ description: "Display order for UI", default: 1 })
@IsOptional()
@IsInt()
@Min(1)
displayOrder?: number = 1;
}

View File

@@ -0,0 +1,46 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
export class FilterServiceTypeDto {
@ApiPropertyOptional({ description: "Filter by active status" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isActive?: boolean;
@ApiPropertyOptional({ description: "Filter by can be booked alone" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ description: "Search by service name (case-insensitive)" })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: ["serviceName", "displayOrder", "createdAt"], default: "displayOrder" })
@IsOptional()
@IsIn(["serviceName", "displayOrder", "createdAt"])
sortBy?: string = "displayOrder";
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "ASC";
@ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreateServiceTypeDto } from "./create-service-type.dto";
export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {}

View File

@@ -0,0 +1,34 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
@Entity({ schema: "freight", name: "service_types" })
@Index(["is_active"])
@Index(["display_order"])
export class ServiceType extends BaseEntity {
@Column({ name: "service_name", type: "varchar", length: 255, nullable: false })
serviceName!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
@Column({ name: "can_be_booked_alone", type: "boolean", default: true })
canBeBookedAlone!: boolean;
@Column({ name: "includes_first_mile", type: "boolean", default: false })
includesFirstMile!: boolean;
@Column({ name: "includes_last_mile", type: "boolean", default: false })
includesLastMile!: boolean;
@Column({ name: "includes_customs", type: "boolean", default: false })
includesCustoms!: boolean;
@Column({ name: "priority_bonus_points", type: "int", default: 0 })
priorityBonusPoints!: number;
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
@Column({ name: "display_order", type: "int", default: 1 })
displayOrder!: number;
}

View File

@@ -0,0 +1,14 @@
import { FindManyOptions } from "typeorm";
import { ServiceType } from "../entities/service-type.entity";
export interface IServiceTypesRepository {
findById(id: string): Promise<ServiceType | null>;
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
create(data: Partial<ServiceType>): Promise<ServiceType>;
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
softDelete(id: string): Promise<void>;
}
export const SERVICE_TYPES_REPOSITORY = Symbol("SERVICE_TYPES_REPOSITORY");

View File

@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CreateServiceTypeDto } from "./dto/create-service-type.dto";
import { FilterServiceTypeDto } from "./dto/filter-service-type.dto";
import { UpdateServiceTypeDto } from "./dto/update-service-type.dto";
import { ServiceTypesService } from "./service-types.service";
@ApiTags("service-types")
@Controller("service-types")
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@ApiOperation({
summary: "List service types",
description: "Paginated list with optional filtering by isActive, canBeBookedAlone, and name search.",
})
findAll(@Query() filter: FilterServiceTypeDto) {
return this.service.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a service type by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@ApiOperation({ summary: "Create a new service type" })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(":id")
@ApiOperation({ summary: "Update a service type" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateServiceTypeDto,
) {
return this.service.update(id, dto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: "Soft-delete a service type" })
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ServiceType } from "./entities/service-type.entity";
import { SERVICE_TYPES_REPOSITORY } from "./interfaces/service-types.repository.interface";
import { ServiceTypesRepository } from "./service-types.repository";
import { ServiceTypesController } from "./service-types.controller";
import { ServiceTypesService } from "./service-types.service";
@Module({
imports: [TypeOrmModule.forFeature([ServiceType])],
controllers: [ServiceTypesController],
providers: [
ServiceTypesRepository,
{
provide: SERVICE_TYPES_REPOSITORY,
useExisting: ServiceTypesRepository,
},
ServiceTypesService,
],
exports: [ServiceTypesService],
})
export class ServiceTypesModule {}

View File

@@ -0,0 +1,30 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindManyOptions, Repository } from "typeorm";
import { ServiceType } from "./entities/service-type.entity";
import { IServiceTypesRepository } from "./interfaces/service-types.repository.interface";
@Injectable()
export class ServiceTypesRepository
extends BaseRepository<ServiceType>
implements IServiceTypesRepository
{
constructor(
@InjectRepository(ServiceType)
repository: Repository<ServiceType>,
) {
super(repository);
}
override findById(id: string): Promise<ServiceType | null> {
return this.repository.findOne({
where: { id },
});
}
override findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]> {
return this.repository.findAndCount(options);
}
}

View File

@@ -0,0 +1,111 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ILike } from "typeorm";
import { CreateServiceTypeDto } from "./dto/create-service-type.dto";
import { FilterServiceTypeDto } from "./dto/filter-service-type.dto";
import { UpdateServiceTypeDto } from "./dto/update-service-type.dto";
import { ServiceType } from "./entities/service-type.entity";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from "./interfaces/service-types.repository.interface";
@Injectable()
export class ServiceTypesService {
constructor(
@Inject(SERVICE_TYPES_REPOSITORY)
private readonly repository: IServiceTypesRepository,
) {}
/**
* Find all service types with pagination and optional filtering.
*/
async findAll(filter: FilterServiceTypeDto): Promise<{
data: ServiceType[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
};
}> {
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) {
where.isActive = filter.isActive;
}
if (filter.canBeBookedAlone !== undefined) {
where.canBeBookedAlone = filter.canBeBookedAlone;
}
if (filter.search) {
where.serviceName = ILike(`%${filter.search}%`);
}
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy!]: filter.sortOrder },
skip: (filter.page! - 1) * filter.pageSize!,
take: filter.pageSize,
});
return {
data,
meta: {
total,
page: filter.page!,
pageSize: filter.pageSize!,
totalPages: Math.ceil(total / filter.pageSize!),
},
};
}
/**
* Get a single service type by ID.
*/
async findById(id: string): Promise<ServiceType> {
const entity = await this.repository.findById(id);
if (!entity) {
throw new NotFoundException(`Service type ${id} not found`);
}
return entity;
}
/**
* Create a new service type.
*/
async create(dto: CreateServiceTypeDto): Promise<ServiceType> {
return this.repository.create({
serviceName: dto.serviceName,
description: dto.description ?? null,
canBeBookedAlone: dto.canBeBookedAlone ?? true,
includesFirstMile: dto.includesFirstMile ?? false,
includesLastMile: dto.includesLastMile ?? false,
includesCustoms: dto.includesCustoms ?? false,
priorityBonusPoints: dto.priorityBonusPoints ?? 0,
isActive: dto.isActive ?? true,
displayOrder: dto.displayOrder ?? 1,
});
}
/**
* Update an existing service type.
*/
async update(id: string, dto: UpdateServiceTypeDto): Promise<ServiceType> {
await this.findById(id);
const updated = await this.repository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Service type ${id} not found`);
}
return this.findById(id);
}
/**
* Soft-delete a service type.
*/
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}