Files
trackgo-be/src/modules/configuration/customers/customers.repository.ts
T
shancheas 4c45a4371e Enhance pagination and ordering capabilities in API responses
- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results.
- Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns.
- Enhanced `PaginationQueryDto` to support ordering fields in API requests.
- Updated various repository and service classes to implement ordering in database queries.
- Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior.
- Refactored related DTOs to include user and code relations for better data representation in responses.
2026-08-27 13:09:41 +07:00

540 lines
15 KiB
TypeScript

import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { attachAuditUsers } from '../../../database/load-user-refs';
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';
const CUSTOMER_ORDER_COLUMNS = {
id: customers.id,
code: customers.code,
name: customers.name,
phone: customers.phone,
address: customers.address,
nfcId: customers.nfcId,
status: customers.status,
createdAt: customers.createdAt,
updatedAt: customers.updatedAt,
};
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(
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: await Promise.all(rows.map((row) => this.hydrate(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.hydrate(row, contacts);
}
async findByCode(code: string): Promise<Customer | null> {
const rows: CustomerRow[] = await this.db
.select()
.from(customers)
.where(eq(customers.code, code))
.limit(1);
const row = rows[0];
if (!row) {
return null;
}
const contacts = await this.selectContacts(this.db, row.id);
return this.hydrate(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.hydrate(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.hydrate(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.hydrate(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 async hydrate(
row: CustomerRow,
contactRows: CustomerContactRow[],
): Promise<Customer> {
const [item] = await attachAuditUsers(this.db, [
this.toDomain(row, contactRows),
]);
return item;
}
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
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 };
}
}