Add customers management module with database schema and validation

- Introduced `CustomersModule` to manage customer data, including read and write controllers.
- Created database migrations for the `customers` and `customer_contacts` tables, including constraints and unique indexes.
- Implemented validation for customer fields such as name, code, and address with corresponding utility functions.
- Developed service and repository layers for handling customer data operations.
- Added unit tests for the customers service, repository, and controllers to ensure functionality and correctness.
- Updated application module to include the new `CustomersModule` for better organization.
This commit is contained in:
shancheas
2026-08-24 14:23:20 +07:00
parent d28506e878
commit cdcc508947
20 changed files with 4123 additions and 2 deletions
@@ -0,0 +1,355 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
CONTACT_JOB_TITLE_MAX_LENGTH,
CONTACT_NAME_MAX_LENGTH,
CONTACT_NOTES_MAX_LENGTH,
CUSTOMER_ADDRESS_MAX_LENGTH,
CUSTOMER_CODE_MAX_LENGTH,
CUSTOMER_CODE_PATTERN,
CUSTOMER_NAME_MAX_LENGTH,
CUSTOMER_NAME_PATTERN,
CUSTOMER_NFC_ID_MAX_LENGTH,
} from '../customer-fields';
export class CreateCustomerContactDto {
@ApiProperty({ example: 'Jean Luc', maxLength: CONTACT_NAME_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CONTACT_NAME_MAX_LENGTH)
name!: string;
@ApiPropertyOptional({ example: 'Purchasing Manager' })
@IsOptional()
@IsString()
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
jobTitle?: string;
@ApiPropertyOptional({ example: '+6281234567890' })
@IsOptional()
@IsString()
@IsNotEmpty()
phone?: string;
@ApiPropertyOptional({ example: '+6281234567891' })
@IsOptional()
@IsString()
@IsNotEmpty()
mobilePhone?: string;
@ApiPropertyOptional({ example: 'Call after 9am' })
@IsOptional()
@IsString()
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
notes?: string;
}
export class UpdateCustomerContactDto {
@ApiPropertyOptional({ example: 'Jean Luc' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CONTACT_NAME_MAX_LENGTH)
name?: string;
@ApiPropertyOptional({ example: 'Purchasing Manager', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CONTACT_JOB_TITLE_MAX_LENGTH)
jobTitle?: string | null;
@ApiPropertyOptional({ example: '+6281234567890', nullable: true })
@IsOptional()
@IsString()
phone?: string | null;
@ApiPropertyOptional({ example: '+6281234567891', nullable: true })
@IsOptional()
@IsString()
mobilePhone?: string | null;
@ApiPropertyOptional({ example: 'Call after 9am', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CONTACT_NOTES_MAX_LENGTH)
notes?: string | null;
}
export class CustomerContactDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty()
name!: string;
@ApiPropertyOptional({ nullable: true })
jobTitle!: string | null;
@ApiPropertyOptional({ nullable: true })
phone!: string | null;
@ApiPropertyOptional({ nullable: true })
mobilePhone!: string | null;
@ApiPropertyOptional({ nullable: true })
notes!: string | null;
}
export class CreateCustomerDto {
@ApiProperty({ example: 'CUST_01', maxLength: CUSTOMER_CODE_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
@Matches(CUSTOMER_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code!: string;
@ApiProperty({ example: 'Acme Corp', maxLength: CUSTOMER_NAME_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
@Matches(CUSTOMER_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name!: string;
@ApiProperty({ example: '+6281234567890' })
@IsString()
@IsNotEmpty()
phone!: string;
@ApiProperty({ example: 'Jl Sudirman No 1' })
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
address!: string;
@ApiPropertyOptional({ example: -6.2 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional({ example: 106.8 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiPropertyOptional({ example: 'NFC-001' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
nfcId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateCustomerContactDto)
contacts?: CreateCustomerContactDto[];
}
export class UpdateCustomerDto {
@ApiPropertyOptional({ example: 'CUST_01' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_CODE_MAX_LENGTH)
@Matches(CUSTOMER_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code?: string;
@ApiPropertyOptional({ example: 'Acme Corp' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_NAME_MAX_LENGTH)
@Matches(CUSTOMER_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name?: string;
@ApiPropertyOptional({ example: '+6281234567890' })
@IsOptional()
@IsString()
@IsNotEmpty()
phone?: string;
@ApiPropertyOptional({ example: 'Jl Sudirman No 1' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(CUSTOMER_ADDRESS_MAX_LENGTH)
address?: string;
@ApiPropertyOptional({ example: -6.2, nullable: true })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number | null;
@ApiPropertyOptional({ example: 106.8, nullable: true })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number | null;
@ApiPropertyOptional({ example: 'NFC-001', nullable: true })
@IsOptional()
@IsString()
@MaxLength(CUSTOMER_NFC_ID_MAX_LENGTH)
nfcId?: string | null;
@ApiPropertyOptional({ type: [CreateCustomerContactDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateCustomerContactDto)
contacts?: CreateCustomerContactDto[];
}
export class UpdateCustomerStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListCustomersQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
phone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
nfcId?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({
description: 'Case-insensitive match on code, name, or address',
})
@IsOptional()
@IsString()
search?: string;
}
export class CustomerDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
@ApiProperty({ example: '+6281234567890' })
phone!: string;
@ApiProperty()
address!: string;
@ApiPropertyOptional({ nullable: true })
latitude!: number | null;
@ApiPropertyOptional({ nullable: true })
longitude!: number | null;
@ApiPropertyOptional({ nullable: true })
nfcId!: string | null;
@ApiPropertyOptional({ type: [CustomerContactDto] })
contacts?: CustomerContactDto[];
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty({ description: 'Unix ms' })
createdAt!: number;
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
}