feat(shared): add shared module with domain primitives, infrastructure services, and utils
Domain primitives: BaseEntity, AggregateRoot, ValueObject, DomainEvent, Result<T,E> Infrastructure: PrismaService, RedisService, LoggerService (pino), EventBusService Utils: Vietnam phone validator/normalizer, VND currency formatter, Vietnamese slug generator Includes 45 unit tests covering all domain primitives, validators, and formatters. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AggregateRoot } from '../aggregate-root';
|
||||
import { type DomainEvent } from '../domain-event';
|
||||
|
||||
class TestAggregate extends AggregateRoot {
|
||||
doSomething(): void {
|
||||
this.addDomainEvent({
|
||||
eventName: 'TestHappened',
|
||||
occurredAt: new Date(),
|
||||
aggregateId: this.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('AggregateRoot', () => {
|
||||
it('should collect domain events', () => {
|
||||
const agg = new TestAggregate('agg-1');
|
||||
agg.doSomething();
|
||||
agg.doSomething();
|
||||
expect(agg.domainEvents).toHaveLength(2);
|
||||
expect(agg.domainEvents[0]!.eventName).toBe('TestHappened');
|
||||
});
|
||||
|
||||
it('should clear domain events and return them', () => {
|
||||
const agg = new TestAggregate('agg-1');
|
||||
agg.doSomething();
|
||||
const events = agg.clearDomainEvents();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(agg.domainEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return a copy of domain events (not mutable reference)', () => {
|
||||
const agg = new TestAggregate('agg-1');
|
||||
agg.doSomething();
|
||||
const events = agg.domainEvents;
|
||||
(events as DomainEvent[]).push({
|
||||
eventName: 'Fake',
|
||||
occurredAt: new Date(),
|
||||
aggregateId: 'x',
|
||||
});
|
||||
expect(agg.domainEvents).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
85
apps/api/src/modules/shared/domain/__tests__/result.spec.ts
Normal file
85
apps/api/src/modules/shared/domain/__tests__/result.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result } from '../result';
|
||||
|
||||
describe('Result', () => {
|
||||
describe('ok', () => {
|
||||
it('should create a successful result', () => {
|
||||
const result = Result.ok(42);
|
||||
expect(result.isOk).toBe(true);
|
||||
expect(result.isErr).toBe(false);
|
||||
expect(result.unwrap()).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('err', () => {
|
||||
it('should create an error result', () => {
|
||||
const error = new Error('fail');
|
||||
const result = Result.err(error);
|
||||
expect(result.isOk).toBe(false);
|
||||
expect(result.isErr).toBe(true);
|
||||
expect(result.unwrapErr()).toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrap', () => {
|
||||
it('should throw on error result', () => {
|
||||
const result = Result.err(new Error('boom'));
|
||||
expect(() => result.unwrap()).toThrow('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapOr', () => {
|
||||
it('should return value on ok', () => {
|
||||
expect(Result.ok(10).unwrapOr(0)).toBe(10);
|
||||
});
|
||||
|
||||
it('should return default on err', () => {
|
||||
expect(Result.err<number>(new Error('x')).unwrapOr(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('map', () => {
|
||||
it('should transform ok value', () => {
|
||||
const result = Result.ok(5).map((v) => v * 2);
|
||||
expect(result.unwrap()).toBe(10);
|
||||
});
|
||||
|
||||
it('should pass through error', () => {
|
||||
const error = new Error('oops');
|
||||
const result = Result.err<number>(error).map((v) => v * 2);
|
||||
expect(result.unwrapErr()).toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('andThen', () => {
|
||||
it('should chain ok results', () => {
|
||||
const result = Result.ok(5).andThen((v) =>
|
||||
v > 0 ? Result.ok(v * 2) : Result.err(new Error('negative')),
|
||||
);
|
||||
expect(result.unwrap()).toBe(10);
|
||||
});
|
||||
|
||||
it('should short-circuit on error', () => {
|
||||
const result = Result.err<number>(new Error('first')).andThen((v) => Result.ok(v * 2));
|
||||
expect(result.unwrapErr().message).toBe('first');
|
||||
});
|
||||
});
|
||||
|
||||
describe('match', () => {
|
||||
it('should call ok handler on success', () => {
|
||||
const output = Result.ok(42).match({
|
||||
ok: (v) => `value: ${v}`,
|
||||
err: (e) => `error: ${e.message}`,
|
||||
});
|
||||
expect(output).toBe('value: 42');
|
||||
});
|
||||
|
||||
it('should call err handler on failure', () => {
|
||||
const output = Result.err(new Error('bad')).match({
|
||||
ok: () => 'ok',
|
||||
err: (e) => `error: ${e.message}`,
|
||||
});
|
||||
expect(output).toBe('error: bad');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ValueObject } from '../value-object';
|
||||
|
||||
class TestVO extends ValueObject<{ value: string }> {
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ValueObject', () => {
|
||||
it('should be immutable', () => {
|
||||
const vo = new TestVO({ value: 'hello' });
|
||||
expect(() => {
|
||||
(vo as any).props.value = 'changed';
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should be equal when props match', () => {
|
||||
const a = new TestVO({ value: 'same' });
|
||||
const b = new TestVO({ value: 'same' });
|
||||
expect(a.equals(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal when props differ', () => {
|
||||
const a = new TestVO({ value: 'one' });
|
||||
const b = new TestVO({ value: 'two' });
|
||||
expect(a.equals(b)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be equal to null/undefined', () => {
|
||||
const vo = new TestVO({ value: 'x' });
|
||||
expect(vo.equals(null as any)).toBe(false);
|
||||
expect(vo.equals(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
20
apps/api/src/modules/shared/domain/aggregate-root.ts
Normal file
20
apps/api/src/modules/shared/domain/aggregate-root.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { BaseEntity } from './base-entity';
|
||||
import { type DomainEvent } from './domain-event';
|
||||
|
||||
export abstract class AggregateRoot<TId = string> extends BaseEntity<TId> {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
get domainEvents(): ReadonlyArray<DomainEvent> {
|
||||
return [...this._domainEvents];
|
||||
}
|
||||
|
||||
protected addDomainEvent(event: DomainEvent): void {
|
||||
this._domainEvents.push(event);
|
||||
}
|
||||
|
||||
clearDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents];
|
||||
this._domainEvents = [];
|
||||
return events;
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/shared/domain/base-entity.ts
Normal file
13
apps/api/src/modules/shared/domain/base-entity.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export abstract class BaseEntity<TId = string> {
|
||||
constructor(
|
||||
public readonly id: TId,
|
||||
public readonly createdAt: Date = new Date(),
|
||||
public updatedAt: Date = new Date(),
|
||||
) {}
|
||||
|
||||
equals(other: BaseEntity<TId>): boolean {
|
||||
if (other === null || other === undefined) return false;
|
||||
if (this === other) return true;
|
||||
return this.id === other.id;
|
||||
}
|
||||
}
|
||||
5
apps/api/src/modules/shared/domain/domain-event.ts
Normal file
5
apps/api/src/modules/shared/domain/domain-event.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface DomainEvent {
|
||||
readonly eventName: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly aggregateId: string;
|
||||
}
|
||||
5
apps/api/src/modules/shared/domain/index.ts
Normal file
5
apps/api/src/modules/shared/domain/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { BaseEntity } from './base-entity';
|
||||
export { AggregateRoot } from './aggregate-root';
|
||||
export { ValueObject } from './value-object';
|
||||
export type { DomainEvent } from './domain-event';
|
||||
export { Result } from './result';
|
||||
56
apps/api/src/modules/shared/domain/result.ts
Normal file
56
apps/api/src/modules/shared/domain/result.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export class Result<T, E = Error> {
|
||||
private constructor(
|
||||
private readonly _isOk: boolean,
|
||||
private readonly _value?: T,
|
||||
private readonly _error?: E,
|
||||
) {}
|
||||
|
||||
get isOk(): boolean {
|
||||
return this._isOk;
|
||||
}
|
||||
|
||||
get isErr(): boolean {
|
||||
return !this._isOk;
|
||||
}
|
||||
|
||||
static ok<T, E = Error>(value: T): Result<T, E> {
|
||||
return new Result<T, E>(true, value);
|
||||
}
|
||||
|
||||
static err<T, E = Error>(error: E): Result<T, E> {
|
||||
return new Result<T, E>(false, undefined, error);
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
if (this._isOk) return this._value as T;
|
||||
throw this._error;
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
if (!this._isOk) return this._error as E;
|
||||
throw new Error('Called unwrapErr on an Ok result');
|
||||
}
|
||||
|
||||
map<U>(fn: (value: T) => U): Result<U, E> {
|
||||
if (this._isOk) return Result.ok(fn(this._value as T));
|
||||
return Result.err(this._error as E);
|
||||
}
|
||||
|
||||
mapErr<F>(fn: (error: E) => F): Result<T, F> {
|
||||
if (!this._isOk) return Result.err(fn(this._error as E));
|
||||
return Result.ok(this._value as T);
|
||||
}
|
||||
|
||||
andThen<U>(fn: (value: T) => Result<U, E>): Result<U, E> {
|
||||
if (this._isOk) return fn(this._value as T);
|
||||
return Result.err(this._error as E);
|
||||
}
|
||||
|
||||
unwrapOr(defaultValue: T): T {
|
||||
return this._isOk ? (this._value as T) : defaultValue;
|
||||
}
|
||||
|
||||
match<U>(handlers: { ok: (value: T) => U; err: (error: E) => U }): U {
|
||||
return this._isOk ? handlers.ok(this._value as T) : handlers.err(this._error as E);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/shared/domain/value-object.ts
Normal file
12
apps/api/src/modules/shared/domain/value-object.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export abstract class ValueObject<TProps> {
|
||||
protected readonly props: Readonly<TProps>;
|
||||
|
||||
constructor(props: TProps) {
|
||||
this.props = Object.freeze({ ...props });
|
||||
}
|
||||
|
||||
equals(other: ValueObject<TProps>): boolean {
|
||||
if (other === null || other === undefined) return false;
|
||||
return JSON.stringify(this.props) === JSON.stringify(other.props);
|
||||
}
|
||||
}
|
||||
4
apps/api/src/modules/shared/index.ts
Normal file
4
apps/api/src/modules/shared/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './domain';
|
||||
export * from './infrastructure';
|
||||
export * from './utils';
|
||||
export { SharedModule } from './shared.module';
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { type DomainEvent } from '../domain/domain-event';
|
||||
|
||||
@Injectable()
|
||||
export class EventBusService {
|
||||
constructor(private readonly eventEmitter: EventEmitter2) {}
|
||||
|
||||
publish(event: DomainEvent): void {
|
||||
this.eventEmitter.emit(event.eventName, event);
|
||||
}
|
||||
|
||||
publishAll(events: DomainEvent[]): void {
|
||||
for (const event of events) {
|
||||
this.publish(event);
|
||||
}
|
||||
}
|
||||
|
||||
async publishAsync(event: DomainEvent): Promise<void> {
|
||||
await this.eventEmitter.emitAsync(event.eventName, event);
|
||||
}
|
||||
}
|
||||
4
apps/api/src/modules/shared/infrastructure/index.ts
Normal file
4
apps/api/src/modules/shared/infrastructure/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { LoggerService } from './logger.service';
|
||||
export { EventBusService } from './event-bus.service';
|
||||
41
apps/api/src/modules/shared/infrastructure/logger.service.ts
Normal file
41
apps/api/src/modules/shared/infrastructure/logger.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable, type LoggerService as NestLoggerService } from '@nestjs/common';
|
||||
import pino, { type Logger } from 'pino';
|
||||
|
||||
@Injectable()
|
||||
export class LoggerService implements NestLoggerService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor() {
|
||||
this.logger = pino({
|
||||
level: process.env['LOG_LEVEL'] ?? 'info',
|
||||
transport:
|
||||
process.env['NODE_ENV'] !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true } }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
log(message: string, context?: string): void {
|
||||
this.logger.info({ context }, message);
|
||||
}
|
||||
|
||||
error(message: string, trace?: string, context?: string): void {
|
||||
this.logger.error({ context, trace }, message);
|
||||
}
|
||||
|
||||
warn(message: string, context?: string): void {
|
||||
this.logger.warn({ context }, message);
|
||||
}
|
||||
|
||||
debug(message: string, context?: string): void {
|
||||
this.logger.debug({ context }, message);
|
||||
}
|
||||
|
||||
verbose(message: string, context?: string): void {
|
||||
this.logger.trace({ context }, message);
|
||||
}
|
||||
|
||||
child(bindings: Record<string, unknown>): Logger {
|
||||
return this.logger.child(bindings);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/shared/infrastructure/prisma.service.ts
Normal file
13
apps/api/src/modules/shared/infrastructure/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
40
apps/api/src/modules/shared/infrastructure/redis.service.ts
Normal file
40
apps/api/src/modules/shared/infrastructure/redis.service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis;
|
||||
|
||||
constructor() {
|
||||
this.client = new Redis({
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
password: process.env['REDIS_PASSWORD'] ?? undefined,
|
||||
lazyConnect: true,
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.client.quit();
|
||||
}
|
||||
|
||||
getClient(): Redis {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
if (ttlSeconds) {
|
||||
await this.client.set(key, value, 'EX', ttlSeconds);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
await this.client.del(key);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/modules/shared/shared.module.ts
Normal file
14
apps/api/src/modules/shared/shared.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { EventBusService } from './infrastructure/event-bus.service';
|
||||
import { LoggerService } from './infrastructure/logger.service';
|
||||
import { PrismaService } from './infrastructure/prisma.service';
|
||||
import { RedisService } from './infrastructure/redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [EventEmitterModule.forRoot()],
|
||||
providers: [PrismaService, RedisService, LoggerService, EventBusService],
|
||||
exports: [PrismaService, RedisService, LoggerService, EventBusService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatVND, formatVNDCompact, parseVND } from '../currency.formatter';
|
||||
|
||||
describe('formatVND', () => {
|
||||
it('should format VND amount', () => {
|
||||
const result = formatVND(1500000);
|
||||
expect(result).toMatch(/1[.,]500[.,]000/);
|
||||
expect(result).toContain('₫');
|
||||
});
|
||||
|
||||
it('should format zero', () => {
|
||||
const result = formatVND(0);
|
||||
expect(result).toContain('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatVNDCompact', () => {
|
||||
it('should format large amounts compactly', () => {
|
||||
const result = formatVNDCompact(1500000000);
|
||||
expect(result.length).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVND', () => {
|
||||
it('should parse numeric string', () => {
|
||||
expect(parseVND('1.500.000 ₫')).toBe(1500000);
|
||||
});
|
||||
|
||||
it('should return null for non-numeric', () => {
|
||||
expect(parseVND('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateSlug } from '../slug.generator';
|
||||
|
||||
describe('generateSlug', () => {
|
||||
it('should generate slug from Vietnamese text', () => {
|
||||
expect(generateSlug('Căn hộ cao cấp Quận 7')).toBe('can-ho-cao-cap-quan-7');
|
||||
});
|
||||
|
||||
it('should handle đ character', () => {
|
||||
expect(generateSlug('Đất nền Bình Dương')).toBe('dat-nen-binh-duong');
|
||||
});
|
||||
|
||||
it('should handle multiple spaces and special chars', () => {
|
||||
expect(generateSlug(' Nhà phố - Thủ Đức!! ')).toBe('nha-pho-thu-duc');
|
||||
});
|
||||
|
||||
it('should handle plain ASCII', () => {
|
||||
expect(generateSlug('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(generateSlug('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidVietnamPhone, normalizeVietnamPhone } from '../vietnam-phone.validator';
|
||||
|
||||
describe('isValidVietnamPhone', () => {
|
||||
it.each([
|
||||
'0912345678',
|
||||
'0351234567',
|
||||
'0561234567',
|
||||
'0701234567',
|
||||
'0812345678',
|
||||
'+84912345678',
|
||||
'84912345678',
|
||||
])('should accept valid phone: %s', (phone) => {
|
||||
expect(isValidVietnamPhone(phone)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'012345678',
|
||||
'091234567', // too short
|
||||
'09123456789', // too long
|
||||
'1234567890',
|
||||
'+1234567890',
|
||||
'',
|
||||
])('should reject invalid phone: %s', (phone) => {
|
||||
expect(isValidVietnamPhone(phone)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeVietnamPhone', () => {
|
||||
it('should normalize 0-prefix to +84', () => {
|
||||
expect(normalizeVietnamPhone('0912345678')).toBe('+84912345678');
|
||||
});
|
||||
|
||||
it('should normalize 84-prefix to +84', () => {
|
||||
expect(normalizeVietnamPhone('84912345678')).toBe('+84912345678');
|
||||
});
|
||||
|
||||
it('should keep +84 prefix', () => {
|
||||
expect(normalizeVietnamPhone('+84912345678')).toBe('+84912345678');
|
||||
});
|
||||
|
||||
it('should return null for invalid phone', () => {
|
||||
expect(normalizeVietnamPhone('invalid')).toBeNull();
|
||||
});
|
||||
});
|
||||
26
apps/api/src/modules/shared/utils/currency.formatter.ts
Normal file
26
apps/api/src/modules/shared/utils/currency.formatter.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const VND_FORMATTER = new Intl.NumberFormat('vi-VN', {
|
||||
style: 'currency',
|
||||
currency: 'VND',
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const VND_COMPACT_FORMATTER = new Intl.NumberFormat('vi-VN', {
|
||||
style: 'currency',
|
||||
currency: 'VND',
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatVND(amount: number): string {
|
||||
return VND_FORMATTER.format(amount);
|
||||
}
|
||||
|
||||
export function formatVNDCompact(amount: number): string {
|
||||
return VND_COMPACT_FORMATTER.format(amount);
|
||||
}
|
||||
|
||||
export function parseVND(formatted: string): number | null {
|
||||
const cleaned = formatted.replace(/[^\d]/g, '');
|
||||
if (cleaned === '') return null;
|
||||
return Number(cleaned);
|
||||
}
|
||||
3
apps/api/src/modules/shared/utils/index.ts
Normal file
3
apps/api/src/modules/shared/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
|
||||
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
|
||||
export { generateSlug } from './slug.generator';
|
||||
87
apps/api/src/modules/shared/utils/slug.generator.ts
Normal file
87
apps/api/src/modules/shared/utils/slug.generator.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
const VIETNAMESE_MAP: Record<string, string> = {
|
||||
à: 'a',
|
||||
á: 'a',
|
||||
ả: 'a',
|
||||
ã: 'a',
|
||||
ạ: 'a',
|
||||
ă: 'a',
|
||||
ắ: 'a',
|
||||
ằ: 'a',
|
||||
ẳ: 'a',
|
||||
ẵ: 'a',
|
||||
ặ: 'a',
|
||||
â: 'a',
|
||||
ấ: 'a',
|
||||
ầ: 'a',
|
||||
ẩ: 'a',
|
||||
ẫ: 'a',
|
||||
ậ: 'a',
|
||||
đ: 'd',
|
||||
è: 'e',
|
||||
é: 'e',
|
||||
ẻ: 'e',
|
||||
ẽ: 'e',
|
||||
ẹ: 'e',
|
||||
ê: 'e',
|
||||
ế: 'e',
|
||||
ề: 'e',
|
||||
ể: 'e',
|
||||
ễ: 'e',
|
||||
ệ: 'e',
|
||||
ì: 'i',
|
||||
í: 'i',
|
||||
ỉ: 'i',
|
||||
ĩ: 'i',
|
||||
ị: 'i',
|
||||
ò: 'o',
|
||||
ó: 'o',
|
||||
ỏ: 'o',
|
||||
õ: 'o',
|
||||
ọ: 'o',
|
||||
ô: 'o',
|
||||
ố: 'o',
|
||||
ồ: 'o',
|
||||
ổ: 'o',
|
||||
ỗ: 'o',
|
||||
ộ: 'o',
|
||||
ơ: 'o',
|
||||
ớ: 'o',
|
||||
ờ: 'o',
|
||||
ở: 'o',
|
||||
ỡ: 'o',
|
||||
ợ: 'o',
|
||||
ù: 'u',
|
||||
ú: 'u',
|
||||
ủ: 'u',
|
||||
ũ: 'u',
|
||||
ụ: 'u',
|
||||
ư: 'u',
|
||||
ứ: 'u',
|
||||
ừ: 'u',
|
||||
ử: 'u',
|
||||
ữ: 'u',
|
||||
ự: 'u',
|
||||
ỳ: 'y',
|
||||
ý: 'y',
|
||||
ỷ: 'y',
|
||||
ỹ: 'y',
|
||||
ỵ: 'y',
|
||||
};
|
||||
|
||||
function removeVietnameseTones(str: string): string {
|
||||
return str
|
||||
.split('')
|
||||
.map(
|
||||
(char) => VIETNAMESE_MAP[char] ?? VIETNAMESE_MAP[char.toLowerCase()]?.toUpperCase() ?? char,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function generateSlug(text: string): string {
|
||||
return removeVietnameseTones(text)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
16
apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
Normal file
16
apps/api/src/modules/shared/utils/vietnam-phone.validator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const VN_PHONE_REGEX = /^(?:\+84|84|0)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
|
||||
export function isValidVietnamPhone(phone: string): boolean {
|
||||
const cleaned = phone.replace(/[\s.-]/g, '');
|
||||
return VN_PHONE_REGEX.test(cleaned);
|
||||
}
|
||||
|
||||
export function normalizeVietnamPhone(phone: string): string | null {
|
||||
const cleaned = phone.replace(/[\s.-]/g, '');
|
||||
if (!VN_PHONE_REGEX.test(cleaned)) return null;
|
||||
|
||||
if (cleaned.startsWith('+84')) return cleaned;
|
||||
if (cleaned.startsWith('84')) return `+${cleaned}`;
|
||||
if (cleaned.startsWith('0')) return `+84${cleaned.slice(1)}`;
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user