customer registration api integration

This commit is contained in:
yaschalew
2026-05-21 02:21:32 +03:00
parent fea308a6af
commit c7347b88d7
39 changed files with 2273 additions and 910 deletions

View File

@@ -1,15 +1,20 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
@ApiTags("customers")
@Controller("customers")
@@ -33,4 +38,20 @@ export class CustomersController {
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.findById(id);
}
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
) {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.remove(id);
}
}

View File

@@ -1,24 +1,32 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new freight customer. */
create(dto: CreateCustomerDto): Promise<Customer> {
async create(dto: CreateCustomerDto): Promise<Customer> {
const existing = await this.customersRepository.findByEmail(dto.email);
if (existing) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
return this.customersRepository.create(dto);
}
/** List every customer (alphabetical). */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({ order: { name: "ASC" } });
}
/** Get a single customer by ID. */
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
@@ -26,4 +34,28 @@ export class CustomersService {
}
return customer;
}
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
if (dto.email) {
const conflict = await this.customersRepository.findByEmail(dto.email);
if (conflict && conflict.id !== id) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
}
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
}

View File

@@ -1,20 +1,73 @@
import { IsEmail, IsOptional, IsString } from "class-validator";
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
export class CreateCustomerDto {
@IsString()
@MaxLength(256)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MaxLength(32)
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
company?: string;
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
@IsOptional()
@IsString()
@MaxLength(64)
tinNumber?: string;
@IsOptional()
@IsString()
@MaxLength(128)
city?: string;
@IsOptional()
@IsString()
@MaxLength(128)
country?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(64)
taxId?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateCustomerDto } from "./create-customer.dto";
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}

View File

@@ -1,6 +1,9 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
@Entity({ name: "customers" })
export class Customer extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 256 })
@@ -12,9 +15,40 @@ export class Customer extends BaseEntity {
@Column({ name: "phone", type: "varchar", length: 32 })
phone!: string;
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
company?: string | null;
@Column({
name: "customer_type",
type: "varchar",
length: 32,
default: "Importer",
})
customerType!: CustomerType;
@Column({
name: "status",
type: "varchar",
length: 32,
default: "Active",
})
status!: CustomerStatus;
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
tinNumber?: string | null;
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
city?: string | null;
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
country?: string | null;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
taxId?: string | null;
@Column({ name: "notes", type: "text", nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownSettingsService } from "./dropdown-settings.service";
@ApiTags("dropdown-settings")
@Controller("dropdown-settings")
export class DropdownSettingsController {
constructor(private readonly service: DropdownSettingsService) {}
@Get()
@ApiOperation({ summary: "List all dropdown settings" })
list() {
return this.service.list();
}
@Get(":id")
@ApiOperation({ summary: "Get a dropdown setting by ID" })
getById(@Param("id", ParseUUIDPipe) id: string) {
return this.service.getById(id);
}
@Get("by-code/:code")
@ApiOperation({ summary: "Get a dropdown setting by its stable code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Post()
@ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateDropdownSettingDto,
) {
return this.service.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.service.remove(id);
}
/* ------------------------- option routes ------------------------- */
@Put(":id/options")
@ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions(
@Param("id", ParseUUIDPipe) id: string,
@Body() options: CreateDropdownOptionDto[],
) {
return this.service.replaceOptions(id, options);
}
@Post(":id/options")
@ApiOperation({ summary: "Append a single option to a setting" })
addOption(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateDropdownOptionDto,
) {
return this.service.addOption(id, dto);
}
@Patch("options/:optionId")
@ApiOperation({ summary: "Update a single option" })
updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string,
@Body() dto: UpdateDropdownOptionDto,
) {
return this.service.updateOption(optionId, dto);
}
@Delete("options/:optionId")
@ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {
return this.service.removeOption(optionId);
}
}

View File

@@ -0,0 +1,24 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { DropdownOption } from "./entities/dropdown-option.entity";
import { DropdownSetting } from "./entities/dropdown-setting.entity";
import { DropdownSettingsController } from "./dropdown-settings.controller";
import { DropdownSettingsRepository } from "./dropdown-settings.repository";
import { DropdownSettingsService } from "./dropdown-settings.service";
import { DROPDOWN_SETTINGS_REPOSITORY } from "./interfaces/dropdown-settings.repository.interface";
@Module({
imports: [TypeOrmModule.forFeature([DropdownSetting, DropdownOption])],
controllers: [DropdownSettingsController],
providers: [
DropdownSettingsRepository,
{
provide: DROPDOWN_SETTINGS_REPOSITORY,
useExisting: DropdownSettingsRepository,
},
DropdownSettingsService,
],
exports: [DropdownSettingsService],
})
export class DropdownSettingsModule {}

View File

@@ -0,0 +1,82 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { DropdownOption } from "./entities/dropdown-option.entity";
import { DropdownSetting } from "./entities/dropdown-setting.entity";
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
@Injectable()
export class DropdownSettingsRepository
extends BaseRepository<DropdownSetting>
implements IDropdownSettingsRepository
{
constructor(
@InjectRepository(DropdownSetting)
repository: Repository<DropdownSetting>,
@InjectRepository(DropdownOption)
private readonly optionsRepository: Repository<DropdownOption>,
) {
super(repository);
}
findByCode(code: string): Promise<DropdownSetting | null> {
return this.repository.findOne({
where: { code },
relations: { children: true },
order: { children: { order: "ASC" } },
});
}
override findById(id: string): Promise<DropdownSetting | null> {
return this.repository.findOne({
where: { id },
relations: { children: true },
order: { children: { order: "ASC" } },
});
}
override findAll(): Promise<DropdownSetting[]> {
return this.repository.find({
order: { label: "ASC", children: { order: "ASC" } },
relations: { children: true },
});
}
async replaceOptions(
settingId: string,
options: Array<Partial<DropdownOption>>,
): Promise<DropdownOption[]> {
await this.optionsRepository.delete({ settingId });
if (options.length === 0) return [];
const entities = options.map((o, idx) =>
this.optionsRepository.create({
...o,
settingId,
order: o.order ?? idx + 1,
}),
);
return this.optionsRepository.save(entities);
}
async addOption(
settingId: string,
option: Partial<DropdownOption>,
): Promise<DropdownOption> {
const entity = this.optionsRepository.create({ ...option, settingId });
return this.optionsRepository.save(entity);
}
async updateOption(
optionId: string,
data: Partial<DropdownOption>,
): Promise<DropdownOption | null> {
await this.optionsRepository.update(optionId, data as never);
return this.optionsRepository.findOne({ where: { id: optionId } });
}
async removeOption(optionId: string): Promise<void> {
await this.optionsRepository.softDelete(optionId);
}
}

View File

@@ -0,0 +1,110 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownOption } from "./entities/dropdown-option.entity";
import { DropdownSetting } from "./entities/dropdown-setting.entity";
import {
DROPDOWN_SETTINGS_REPOSITORY,
IDropdownSettingsRepository,
} from "./interfaces/dropdown-settings.repository.interface";
@Injectable()
export class DropdownSettingsService {
constructor(
@Inject(DROPDOWN_SETTINGS_REPOSITORY)
private readonly repository: IDropdownSettingsRepository,
) {}
list(): Promise<DropdownSetting[]> {
return this.repository.findAll();
}
async getById(id: string): Promise<DropdownSetting> {
const setting = await this.repository.findById(id);
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
return setting;
}
async getByCode(code: string): Promise<DropdownSetting> {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
return setting;
}
async create(dto: CreateDropdownSettingDto): Promise<DropdownSetting> {
const existing = await this.repository.findByCode(dto.code);
if (existing) {
throw new ConflictException(
`Dropdown setting with code "${dto.code}" already exists`,
);
}
const setting = await this.repository.create({
code: dto.code,
label: dto.label,
description: dto.description ?? null,
multiple: dto.multiple ?? false,
meta: dto.meta ?? null,
});
if (dto.children && dto.children.length > 0) {
await this.repository.replaceOptions(setting.id, dto.children);
}
return this.getById(setting.id);
}
async update(
id: string,
dto: UpdateDropdownSettingDto,
): Promise<DropdownSetting> {
await this.getById(id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Setting ${id} not found`);
return this.getById(id);
}
async remove(id: string): Promise<void> {
await this.getById(id);
await this.repository.softDelete(id);
}
/* ------------------------ option operations ------------------------ */
async replaceOptions(
settingId: string,
options: CreateDropdownOptionDto[],
): Promise<DropdownOption[]> {
await this.getById(settingId);
return this.repository.replaceOptions(settingId, options);
}
async addOption(
settingId: string,
dto: CreateDropdownOptionDto,
): Promise<DropdownOption> {
await this.getById(settingId);
return this.repository.addOption(settingId, dto);
}
async updateOption(
optionId: string,
dto: UpdateDropdownOptionDto,
): Promise<DropdownOption> {
const updated = await this.repository.updateOption(optionId, dto);
if (!updated) throw new NotFoundException(`Option ${optionId} not found`);
return updated;
}
async removeOption(optionId: string): Promise<void> {
await this.repository.removeOption(optionId);
}
}

View File

@@ -0,0 +1,40 @@
import { Type } from "class-transformer";
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { DropdownOptionMetaDto } from "./dropdown-option-meta.dto";
export class CreateDropdownOptionDto {
@IsString()
@MaxLength(256)
value!: string;
@IsString()
@MaxLength(256)
label!: string;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsBoolean()
disabled?: boolean;
@IsOptional()
@IsInt()
@Min(0)
order?: number;
@IsOptional()
@ValidateNested()
@Type(() => DropdownOptionMetaDto)
meta?: DropdownOptionMetaDto;
}

View File

@@ -0,0 +1,45 @@
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from "class-validator";
import { CreateDropdownOptionDto } from "./create-dropdown-option.dto";
import { DropdownSettingMetaDto } from "./dropdown-setting-meta.dto";
export class CreateDropdownSettingDto {
@IsString()
@MaxLength(128)
@Matches(/^[a-z][a-z0-9_]*$/i, {
message: "code must be snake_case-friendly (letters, digits, underscores)",
})
code!: string;
@IsString()
@MaxLength(256)
label!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsBoolean()
multiple?: boolean;
@IsOptional()
@ValidateNested()
@Type(() => DropdownSettingMetaDto)
meta?: DropdownSettingMetaDto;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateDropdownOptionDto)
children?: CreateDropdownOptionDto[];
}

View File

@@ -0,0 +1,18 @@
import { IsOptional, IsString, MaxLength } from "class-validator";
export class DropdownOptionMetaDto {
@IsOptional()
@IsString()
@MaxLength(64)
icon?: string;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string;
@IsOptional()
@IsString()
@MaxLength(64)
badge?: string;
}

View File

@@ -0,0 +1,43 @@
import { Transform } from "class-transformer";
import {
IsArray,
IsBoolean,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export class DropdownSettingMetaDto {
@IsOptional()
@IsString()
@MaxLength(64)
icon?: string;
@IsOptional()
@IsString()
@MaxLength(32)
color?: string;
@IsOptional()
@IsBoolean()
searchable?: boolean;
@IsOptional()
@IsBoolean()
clearable?: boolean;
@IsOptional()
@IsArray()
@IsString({ each: true })
@Transform(({ value }) =>
Array.isArray(value)
? (value as string[]).map((s) => s.trim()).filter(Boolean)
: value,
)
permissions?: string[];
@IsOptional()
@IsString()
@MaxLength(32)
version?: string;
}

View File

@@ -0,0 +1,7 @@
import { PartialType } from "@nestjs/swagger";
import { CreateDropdownOptionDto } from "./create-dropdown-option.dto";
export class UpdateDropdownOptionDto extends PartialType(
CreateDropdownOptionDto,
) {}

View File

@@ -0,0 +1,7 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { CreateDropdownSettingDto } from "./create-dropdown-setting.dto";
export class UpdateDropdownSettingDto extends PartialType(
OmitType(CreateDropdownSettingDto, ["children"] as const),
) {}

View File

@@ -0,0 +1,47 @@
import { BaseEntity } from "@edr/api-common";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
} from "typeorm";
import { DropdownSetting } from "./dropdown-setting.entity";
export interface DropdownOptionMeta {
icon?: string;
color?: string;
badge?: string;
}
@Entity({ name: "dropdown_options" })
@Index(["settingId", "value"], { unique: true })
export class DropdownOption extends BaseEntity {
@ManyToOne(() => DropdownSetting, (setting) => setting.children, {
onDelete: "CASCADE",
})
@JoinColumn({ name: "setting_id" })
setting!: DropdownSetting;
@Column({ name: "setting_id", type: "uuid" })
settingId!: string;
@Column({ name: "value", type: "varchar", length: 256 })
value!: string;
@Column({ name: "label", type: "varchar", length: 256 })
label!: string;
@Column({ name: "note", type: "text", nullable: true })
note?: string | null;
@Column({ name: "is_disabled", type: "boolean", default: false })
disabled!: boolean;
@Column({ name: "display_order", type: "integer", default: 0 })
order!: number;
@Column({ name: "meta", type: "jsonb", nullable: true })
meta?: DropdownOptionMeta | null;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, OneToMany } from "typeorm";
import { DropdownOption } from "./dropdown-option.entity";
export interface DropdownSettingMeta {
icon?: string;
color?: string;
searchable?: boolean;
clearable?: boolean;
permissions?: string[];
version?: string;
}
@Entity({ name: "dropdown_settings" })
@Index(["code"], { unique: true })
export class DropdownSetting extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 128, unique: true })
code!: string;
@Column({ name: "label", type: "varchar", length: 256 })
label!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
@Column({ name: "multiple", type: "boolean", default: false })
multiple!: boolean;
@Column({ name: "meta", type: "jsonb", nullable: true })
meta?: DropdownSettingMeta | null;
@OneToMany(() => DropdownOption, (option) => option.setting, {
cascade: true,
})
children!: DropdownOption[];
}

View File

@@ -0,0 +1,38 @@
import { DropdownOption } from "../entities/dropdown-option.entity";
import { DropdownSetting } from "../entities/dropdown-setting.entity";
/**
* Contract every DropdownSettings repository must satisfy. Lets services
* depend on the abstraction and lets tests swap in an in-memory fake.
*/
export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
"DROPDOWN_SETTINGS_REPOSITORY",
);
export interface IDropdownSettingsRepository {
findAll(): Promise<DropdownSetting[]>;
findById(id: string): Promise<DropdownSetting | null>;
findByCode(code: string): Promise<DropdownSetting | null>;
create(data: Partial<DropdownSetting>): Promise<DropdownSetting>;
update(
id: string,
data: Partial<DropdownSetting>,
): Promise<DropdownSetting | null>;
softDelete(id: string): Promise<void>;
/* Option-level helpers */
replaceOptions(
settingId: string,
options: Array<Partial<DropdownOption>>,
): Promise<DropdownOption[]>;
addOption(
settingId: string,
option: Partial<DropdownOption>,
): Promise<DropdownOption>;
updateOption(
optionId: string,
data: Partial<DropdownOption>,
): Promise<DropdownOption | null>;
removeOption(optionId: string): Promise<void>;
}