diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts new file mode 100644 index 000000000..cf6ca7a6e --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.spec.ts @@ -0,0 +1,54 @@ +import { + validate, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; +import { IsTin, normalizeTin } from './is-tin.validator'; + +class Required { + @IsString() + @IsNotEmpty() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin!: string; +} + +class Optional { + @IsOptional() + @IsString() + @IsTin({ message: 'TIN must be exactly 10 digits' }) + tin?: string; +} + +async function errs(cls: any, tin: any) { + const o = new cls(); + o.tin = tin; + return (await validate(o)).length; +} + +describe('IsTin', () => { + it('accepts a real 10-digit TIN', async () => { + expect(await errs(Required, '0012345678')).toBe(0); + }); + + it.each([ + ['letters', 'ABCDEFGHIJ'], + ['symbols', '!!!!!!!!!!'], + ['too short', '123'], + ['too long', '12345678901'], + ['draft TIN', 'D123456789'], + ['spaced', '012 345678'], + ])('rejects %s', async (_label, value) => { + expect(await errs(Required, value)).toBeGreaterThan(0); + }); + + it('rejects empty on the required DTO but allows omission on the optional one', async () => { + expect(await errs(Required, '')).toBeGreaterThan(0); + expect(await errs(Optional, undefined)).toBe(0); + }); + + it('normalizes messy input', () => { + expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678'); + expect(normalizeTin('')).toBe(''); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-tin.validator.ts b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts new file mode 100644 index 000000000..9396a884d --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-tin.validator.ts @@ -0,0 +1,54 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** An Ethiopian TIN is exactly 10 digits. */ +export const TIN_REGEX = /^\d{10}$/; + +/** + * Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by + * CompaniesService.generateDraftTin(), because the column is NOT NULL + unique. + * Those never travel through a DTO, so this constraint deliberately rejects them + * — a "D…" value arriving on a request body is client-supplied and invalid. + */ +@ValidatorConstraint({ name: 'IsTin', async: false }) +export class IsTinConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. + if (value === undefined || value === null || value === '') return true; + if (typeof value !== 'string') return false; + return TIN_REGEX.test(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be exactly 10 digits`; + } +} + +/** Class-validator decorator enforcing the 10-digit TIN format. */ +export function IsTin(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsTinConstraint, + }); + }; +} + +/** + * Strip everything that isn't a digit and cap at 10 characters. Tolerant — + * never throws; returns the value unchanged when empty/nullish. + */ +export function normalizeTin( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined || value === null || value === '') return value; + return value.replace(/\D/g, '').slice(0, 10); +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 6a0365854..b02f38380 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -67,6 +67,8 @@ export class CompaniesRepository extends BaseRepository { kind, status, onboardingCompleted, + sortBy = 'name', + sortOrder = 'ASC', } = query; const qb = this.repository @@ -113,8 +115,12 @@ export class CompaniesRepository extends BaseRepository { ); } + // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. const [items, total] = await qb - .orderBy('company.name', 'ASC') + .orderBy(`company.${sortBy}`, sortOrder) + // Names are not unique and createdAt can tie on bulk imports; the id + // tiebreaker keeps paging stable instead of dropping/repeating rows. + .addOrderBy('company.id', 'ASC') .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index eb32f72ae..7816572ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -45,7 +46,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() - @MaxLength(10) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index a56ea5ad8..be911b3ec 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class CreateCompanyDto { @IsString() @@ -17,7 +18,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts index 2eb37c92d..466c03ed6 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -1,8 +1,9 @@ -import { IsString, IsNotEmpty, Length } from "class-validator"; +import { IsString, IsNotEmpty } from "class-validator"; +import { IsTin } from "../../../common/validators/is-tin.validator"; export class FetchETradeDto { @IsString() @IsNotEmpty() - @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + @IsTin({ message: "TIN must be exactly 10 digits" }) tin!: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index adaa12479..8b5083e5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -47,4 +47,19 @@ export class ListCompaniesQueryDto { @Transform(({ value }: { value: unknown }) => value === "true" || value === true) @IsBoolean() onboardingCompleted?: boolean; + + @ApiPropertyOptional({ + enum: ["name", "createdAt", "updatedAt"], + default: "name", + description: "Column to order by. Defaults to name for backwards compatibility.", + }) + @IsOptional() + @IsIn(["name", "createdAt", "updatedAt"]) + sortBy?: "name" | "createdAt" | "updatedAt"; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 9fd8f28ae..a1b1ac9df 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,6 +1,7 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; +import { IsTin } from '../../../common/validators/is-tin.validator'; export class UpdateProfileDto { @IsOptional() @@ -34,7 +35,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) + @IsTin({ message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 89325ae79..c1312a6ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -5,6 +5,7 @@ import { Card, Group, SegmentedControl, + Select, Stack, Text, TextInput, @@ -63,22 +64,35 @@ const VIEW_FILTERS: Record< active: { status: "active" }, }; +const SORT_OPTIONS = [ + { value: "createdAt:DESC", label: "Newest first" }, + { value: "createdAt:ASC", label: "Oldest first" }, + { value: "name:ASC", label: "Name (A–Z)" }, + { value: "name:DESC", label: "Name (Z–A)" }, +] as const; + export default function CustomersPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); const [view, setView] = useState("all"); + const [sort, setSort] = useState("createdAt:DESC"); - const filter = useMemo( - () => ({ + const filter = useMemo(() => { + const [sortBy, sortOrder] = sort.split(":") as [ + "name" | "createdAt" | "updatedAt", + "ASC" | "DESC", + ]; + return { page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, + sortBy, + sortOrder, ...VIEW_FILTERS[view], - }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery, view], - ); + }; + }, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -291,6 +305,20 @@ export default function CustomersPage() { { label: "Active", value: "active" }, ]} /> + + diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 27011a9d7..597dfa509 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -32,7 +32,7 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), vatNumber: z .string()