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:
@@ -0,0 +1,500 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
customerContacts,
|
||||
customers,
|
||||
type CustomerContactRow,
|
||||
type CustomerRow,
|
||||
type NewCustomerRow,
|
||||
} from '../../../database/customers-table';
|
||||
import type {
|
||||
CreateCustomerInput,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
CustomerContactInput,
|
||||
ListCustomersFilters,
|
||||
UpdateCustomerContactInput,
|
||||
UpdateCustomerInput,
|
||||
} from './customer';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListCustomersFilters,
|
||||
): Promise<{ data: Customer[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(customers)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(customers).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(customers.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [])),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for modules to add joins/extra predicates without forking list.
|
||||
*/
|
||||
extendListQuery<T>(qb: T, filters: ListCustomersFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Customer | null> {
|
||||
const rows: CustomerRow[] = await this.db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, id);
|
||||
return this.toDomain(row, contacts);
|
||||
}
|
||||
|
||||
async create(input: CreateCustomerInput): Promise<Customer> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const inserted = await tx
|
||||
.insert(customers)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
||||
const contacts = await this.selectContacts(tx, row.id);
|
||||
return this.toDomain(row, contacts);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateCustomerInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const input of inputs) {
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
const inserted = await tx
|
||||
.insert(customers)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
||||
}
|
||||
});
|
||||
return inputs.length;
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateCustomerInput): Promise<Customer> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const values: Partial<NewCustomerRow> = {
|
||||
code: input.code ?? existing.code,
|
||||
name: input.name ?? existing.name,
|
||||
phone: input.phone?.value ?? existing.phone.value,
|
||||
address: input.address ?? existing.address,
|
||||
latitude:
|
||||
input.latitude !== undefined ? input.latitude : existing.latitude,
|
||||
longitude:
|
||||
input.longitude !== undefined
|
||||
? input.longitude
|
||||
: existing.longitude,
|
||||
nfcId: input.nfcId !== undefined ? input.nfcId : existing.nfcId,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
};
|
||||
const updated = await tx
|
||||
.update(customers)
|
||||
.set(values)
|
||||
.where(eq(customers.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
if (input.contacts !== undefined) {
|
||||
await this.replaceContacts(tx, id, input.contacts);
|
||||
}
|
||||
const contacts = await this.selectContacts(tx, id);
|
||||
return this.toDomain(row, contacts);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Customer> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(customers.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const contacts = await this.selectContacts(this.db, id);
|
||||
return this.toDomain(row, contacts);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(customers.id, ids))
|
||||
.returning({ id: customers.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(customers)
|
||||
.where(eq(customers.id, id))
|
||||
.returning({ id: customers.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(customers)
|
||||
.where(inArray(customers.id, ids))
|
||||
.returning({ id: customers.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
async addContact(
|
||||
customerId: string,
|
||||
input: CustomerContactInput,
|
||||
userId: string,
|
||||
): Promise<Customer> {
|
||||
const existing = await this.findById(customerId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
await this.db.insert(customerContacts).values({
|
||||
customerId,
|
||||
name: input.name,
|
||||
jobTitle: input.jobTitle ?? null,
|
||||
phone: input.phone?.value ?? null,
|
||||
mobilePhone: input.mobilePhone?.value ?? null,
|
||||
notes: input.notes ?? null,
|
||||
});
|
||||
await this.touchCustomer(customerId, userId, now);
|
||||
const found = await this.findById(customerId);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async updateContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
input: UpdateCustomerContactInput,
|
||||
): Promise<Customer> {
|
||||
const existing = await this.findById(customerId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
const current = existing.contacts.find((c) => c.id === contactId);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(customerContacts)
|
||||
.set({
|
||||
name: input.name ?? current.name,
|
||||
jobTitle:
|
||||
input.jobTitle !== undefined ? input.jobTitle : current.jobTitle,
|
||||
phone:
|
||||
input.phone !== undefined
|
||||
? (input.phone?.value ?? null)
|
||||
: (current.phone?.value ?? null),
|
||||
mobilePhone:
|
||||
input.mobilePhone !== undefined
|
||||
? (input.mobilePhone?.value ?? null)
|
||||
: (current.mobilePhone?.value ?? null),
|
||||
notes: input.notes !== undefined ? input.notes : current.notes,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(customerContacts.id, contactId),
|
||||
eq(customerContacts.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.returning({ id: customerContacts.id });
|
||||
if (updated.length === 0) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
await this.touchCustomer(customerId, input.userId, now);
|
||||
const found = await this.findById(customerId);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Customer not found');
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async deleteContact(customerId: string, contactId: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(customerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(customerContacts.id, contactId),
|
||||
eq(customerContacts.customerId, customerId),
|
||||
),
|
||||
)
|
||||
.returning({ id: customerContacts.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Contact not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async touchCustomer(
|
||||
customerId: string,
|
||||
userId: string,
|
||||
now: DateTime,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(customers)
|
||||
.set({
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(customers.id, customerId));
|
||||
}
|
||||
|
||||
private async selectContacts(
|
||||
executor: QueryExecutor,
|
||||
customerId: string,
|
||||
): Promise<CustomerContactRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(customerContacts)
|
||||
.where(eq(customerContacts.customerId, customerId))
|
||||
.orderBy(asc(customerContacts.name));
|
||||
}
|
||||
|
||||
private async replaceContacts(
|
||||
executor: QueryExecutor,
|
||||
customerId: string,
|
||||
contacts: readonly CustomerContactInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(customerContacts)
|
||||
.where(eq(customerContacts.customerId, customerId));
|
||||
if (contacts.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(customerContacts).values(
|
||||
contacts.map((contact) => ({
|
||||
customerId,
|
||||
name: contact.name,
|
||||
jobTitle: contact.jobTitle ?? null,
|
||||
phone: contact.phone?.value ?? null,
|
||||
mobilePhone: contact.mobilePhone?.value ?? null,
|
||||
notes: contact.notes ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListCustomersFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(customers.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.name) {
|
||||
parts.push(ilike(customers.name, `%${filters.name}%`));
|
||||
}
|
||||
if (filters.phone) {
|
||||
parts.push(ilike(customers.phone, `%${filters.phone}%`));
|
||||
}
|
||||
if (filters.address) {
|
||||
parts.push(ilike(customers.address, `%${filters.address}%`));
|
||||
}
|
||||
if (filters.nfcId) {
|
||||
parts.push(eq(customers.nfcId, filters.nfcId));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(customers.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(customers.code, `%${filters.search}%`),
|
||||
ilike(customers.name, `%${filters.search}%`),
|
||||
ilike(customers.address, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateCustomerInput,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
phone: input.phone.value,
|
||||
address: input.address,
|
||||
latitude: input.latitude ?? null,
|
||||
longitude: input.longitude ?? null,
|
||||
nfcId: input.nfcId ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: CustomerRow,
|
||||
contactRows: CustomerContactRow[],
|
||||
): Customer {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
phone: PhoneNumber.create(row.phone),
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
nfcId: row.nfcId,
|
||||
contacts: contactRows.map((contact) => this.toContactDomain(contact)),
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private toContactDomain(row: CustomerContactRow): CustomerContact {
|
||||
return {
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
jobTitle: row.jobTitle,
|
||||
phone: row.phone ? PhoneNumber.create(row.phone) : null,
|
||||
mobilePhone: row.mobilePhone ? PhoneNumber.create(row.mobilePhone) : null,
|
||||
notes: row.notes,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
const constraint = err.constraint ?? '';
|
||||
if (constraint.includes('nfc')) {
|
||||
throw new ConflictException('Customer NFC ID already exists');
|
||||
}
|
||||
throw new ConflictException('Customer code already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
} {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
constraint_name?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return {
|
||||
code: obj.code,
|
||||
constraint: obj.constraint ?? obj.constraint_name,
|
||||
};
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string; constraint?: string };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user