diff --git a/.cursor/rules/pagination-response.mdc b/.cursor/rules/pagination-response.mdc index 3021884..33c6288 100644 --- a/.cursor/rules/pagination-response.mdc +++ b/.cursor/rules/pagination-response.mdc @@ -36,7 +36,7 @@ interface PaginationMeta { - Mark every list endpoint with `@Pagination()` - Return `{ data, total }` from the handler — **never** build `meta` in the service or controller -- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`) +- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`) plus `orderBy`/`orderType` (`ASC` | `DESC`, default `ASC`) - Use `@RawResponse()` for file downloads / health probes that must skip wrapping - Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped** diff --git a/.cursor/rules/read-write-controllers.mdc b/.cursor/rules/read-write-controllers.mdc index 0c551d2..a093ffd 100644 --- a/.cursor/rules/read-write-controllers.mdc +++ b/.cursor/rules/read-write-controllers.mdc @@ -35,7 +35,7 @@ Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before** List requirements: - Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters) -- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto` +- Shared pagination query (`page`/`limit` or `offset`/`limit`) plus `orderBy`/`orderType` via `PaginationQueryDto` - Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`) - Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller - FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc` diff --git a/src/common/http/response/index.ts b/src/common/http/response/index.ts index d771ae6..bc55cc1 100644 --- a/src/common/http/response/index.ts +++ b/src/common/http/response/index.ts @@ -23,12 +23,27 @@ export { PAGINATION_MAX_LIMIT, } from './pagination.constants'; export { + CODE_RELATION_FIELDS, DEFAULT_RELATION_FIELDS, + fallbackUserRelation, + pickCodeRelation, pickDefaultRelation, pickRelation, pickUserRelation, USER_RELATION_FIELDS, + type CodeRelation, type DefaultRelation, type UserRelation, } from './relation-fields'; -export { DefaultRelationDto, UserRelationDto } from './relation.dto'; +export { + CodeRelationDto, + DefaultRelationDto, + UserRelationDto, +} from './relation.dto'; +export { + ORDER_TYPES, + toOrderClauses, + type ListOrderQuery, + type OrderDefault, + type OrderType, +} from './order-clause'; diff --git a/src/common/http/response/order-clause.spec.ts b/src/common/http/response/order-clause.spec.ts new file mode 100644 index 0000000..7f29b0e --- /dev/null +++ b/src/common/http/response/order-clause.spec.ts @@ -0,0 +1,71 @@ +import { BadRequestException } from '@nestjs/common'; +import { asc, desc } from 'drizzle-orm'; +import { integer, pgTable } from 'drizzle-orm/pg-core'; +import { toOrderClauses } from './order-clause'; + +const sample = pgTable('sample', { + code: integer('code'), + name: integer('name'), + createdAt: integer('created_at'), +}); + +const columns = { + code: sample.code, + name: sample.name, + createdAt: sample.createdAt, +}; + +describe('toOrderClauses', () => { + it('uses default columns when orderBy is omitted', () => { + expect( + toOrderClauses(columns, {}, [{ column: 'code', type: 'ASC' }]), + ).toEqual([asc(sample.code)]); + }); + + it('applies multiple defaults when orderBy is omitted', () => { + expect( + toOrderClauses(columns, {}, [ + { column: 'createdAt', type: 'ASC' }, + { column: 'code', type: 'ASC' }, + ]), + ).toEqual([asc(sample.createdAt), asc(sample.code)]); + }); + + it('uses a single client column and DESC', () => { + expect( + toOrderClauses(columns, { orderBy: 'name', orderType: 'DESC' }, [ + { column: 'code', type: 'ASC' }, + ]), + ).toEqual([desc(sample.name)]); + }); + + it('applies orderType to defaults when orderBy is omitted', () => { + expect( + toOrderClauses(columns, { orderType: 'DESC' }, [ + { column: 'code', type: 'ASC' }, + ]), + ).toEqual([desc(sample.code)]); + }); + + it('rejects unknown orderBy without echoing the raw value as SQL', () => { + expect(() => + toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]), + ).toThrow(BadRequestException); + try { + toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]); + } catch (error) { + expect((error as BadRequestException).message).toContain( + 'code, name, createdAt', + ); + expect((error as BadRequestException).message).not.toContain( + 'drop table', + ); + } + }); + + it('rejects invalid orderType', () => { + expect(() => + toOrderClauses(columns, { orderType: 'SIDEWAYS' }, [{ column: 'code' }]), + ).toThrow(BadRequestException); + }); +}); diff --git a/src/common/http/response/order-clause.ts b/src/common/http/response/order-clause.ts new file mode 100644 index 0000000..6f92d8a --- /dev/null +++ b/src/common/http/response/order-clause.ts @@ -0,0 +1,57 @@ +import { BadRequestException } from '@nestjs/common'; +import { asc, desc, type SQL } from 'drizzle-orm'; + +export const ORDER_TYPES = ['ASC', 'DESC'] as const; +export type OrderType = (typeof ORDER_TYPES)[number]; + +export type ListOrderQuery = { + readonly orderBy?: string; + readonly orderType?: string; +}; + +export type OrderDefault = { + readonly column?: string; + readonly type?: OrderType; +}; + +export function toOrderClauses( + columns: Record[0]>, + query: ListOrderQuery, + defaults: readonly OrderDefault[], +): SQL[] { + if (query.orderBy) { + return [toSql(columns, query.orderBy, normalizeOrderType(query.orderType))]; + } + const typeOverride = + query.orderType != null && query.orderType !== '' + ? normalizeOrderType(query.orderType) + : undefined; + return defaults.map((entry) => + toSql(columns, entry.column ?? '', typeOverride ?? entry.type ?? 'ASC'), + ); +} + +function toSql( + columns: Record[0]>, + column: string, + type: OrderType, +): SQL { + const selected = columns[column]; + if (selected == null) { + throw new BadRequestException( + `Invalid orderBy. Allowed: ${Object.keys(columns).join(', ')}`, + ); + } + return type === 'DESC' ? desc(selected) : asc(selected); +} + +function normalizeOrderType(raw?: string): OrderType { + if (raw == null || raw === '') { + return 'ASC'; + } + const normalized = raw.toUpperCase(); + if (normalized === 'ASC' || normalized === 'DESC') { + return normalized; + } + throw new BadRequestException('Invalid orderType. Allowed: ASC, DESC'); +} diff --git a/src/common/http/response/pagination-query.dto.ts b/src/common/http/response/pagination-query.dto.ts index 3382972..4302c65 100644 --- a/src/common/http/response/pagination-query.dto.ts +++ b/src/common/http/response/pagination-query.dto.ts @@ -1,5 +1,7 @@ -import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Max, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ORDER_TYPES } from './order-clause'; import { PAGINATION_MAX_LIMIT } from './pagination.constants'; export class PaginationQueryDto { @@ -21,4 +23,19 @@ export class PaginationQueryDto { @IsInt() @Min(0) offset?: number; + + @ApiPropertyOptional({ + description: 'Column to order by (resource response field name)', + }) + @IsOptional() + @IsString() + orderBy?: string; + + @ApiPropertyOptional({ enum: ORDER_TYPES, default: 'ASC' }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.toUpperCase() : value, + ) + @IsIn([...ORDER_TYPES]) + orderType?: (typeof ORDER_TYPES)[number]; } diff --git a/src/common/http/response/relation-fields.spec.ts b/src/common/http/response/relation-fields.spec.ts index 7239808..041a1b7 100644 --- a/src/common/http/response/relation-fields.spec.ts +++ b/src/common/http/response/relation-fields.spec.ts @@ -1,5 +1,7 @@ import { + CODE_RELATION_FIELDS, DEFAULT_RELATION_FIELDS, + pickCodeRelation, pickDefaultRelation, pickRelation, pickUserRelation, @@ -60,6 +62,19 @@ describe('pickRelation', () => { }); }); +describe('pickCodeRelation', () => { + it('picks id and code only', () => { + expect( + pickCodeRelation({ + id: 'so-1', + code: 'SO-001', + }), + ).toEqual({ id: 'so-1', code: 'SO-001' }); + expect(CODE_RELATION_FIELDS).toEqual(['id', 'code']); + expect(pickCodeRelation(null)).toBeNull(); + }); +}); + describe('pickDefaultRelation / pickUserRelation', () => { it('maps catalog and user sources without leaking extra fields', () => { expect( diff --git a/src/common/http/response/relation-fields.ts b/src/common/http/response/relation-fields.ts index d7c1c1b..8472a1a 100644 --- a/src/common/http/response/relation-fields.ts +++ b/src/common/http/response/relation-fields.ts @@ -1,5 +1,6 @@ export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const; export const USER_RELATION_FIELDS = ['id', 'username'] as const; +export const CODE_RELATION_FIELDS = ['id', 'code'] as const; export type DefaultRelation = { readonly id: string; @@ -12,6 +13,11 @@ export type UserRelation = { readonly username: string; }; +export type CodeRelation = { + readonly id: string; + readonly code: string; +}; + export function pickRelation>( source: T | null | undefined, fields: readonly K[], @@ -40,3 +46,16 @@ export function pickUserRelation(source: UserRelation): UserRelation { } ); } + +export function pickCodeRelation( + source: CodeRelation | null | undefined, +): CodeRelation | null { + return pickRelation(source, CODE_RELATION_FIELDS); +} + +export function fallbackUserRelation( + source: UserRelation | null | undefined, + fallbackId: string, +): UserRelation { + return pickUserRelation(source ?? { id: fallbackId, username: '' }); +} diff --git a/src/common/http/response/relation.dto.ts b/src/common/http/response/relation.dto.ts index 9575aa5..f7a7337 100644 --- a/src/common/http/response/relation.dto.ts +++ b/src/common/http/response/relation.dto.ts @@ -18,3 +18,11 @@ export class UserRelationDto { @ApiProperty() username!: string; } + +export class CodeRelationDto { + @ApiProperty({ format: 'uuid' }) + id!: string; + + @ApiProperty() + code!: string; +} diff --git a/src/database/employees-table.ts b/src/database/employees-table.ts index 515019f..e4d1620 100644 --- a/src/database/employees-table.ts +++ b/src/database/employees-table.ts @@ -14,7 +14,9 @@ export const employees = pgTable( name: varchar('name', { length: 64 }).notNull(), phone: text('phone').notNull(), position: text('position').notNull(), - userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), + userId: uuid('user_id').references(() => users.id, { + onDelete: 'set null', + }), ...primaryEntityColumns(users), }, (t) => [ diff --git a/src/database/load-catalog-refs.ts b/src/database/load-catalog-refs.ts new file mode 100644 index 0000000..1f70845 --- /dev/null +++ b/src/database/load-catalog-refs.ts @@ -0,0 +1,129 @@ +import { inArray } from 'drizzle-orm'; +import type { CodeRelation, DefaultRelation } from '../common/http/response'; +import type { DrizzleDB } from './database.module'; +import { + branches, + customers, + divisions, + employees, + packingSlips, + products, + salesInvoices, + salesOrders, + salesRequests, +} from './schema'; + +type CatalogTable = + | typeof employees + | typeof branches + | typeof divisions + | typeof customers + | typeof products; + +type CodeTable = + | typeof salesOrders + | typeof salesRequests + | typeof packingSlips + | typeof salesInvoices; + +async function loadDefaultMap( + db: DrizzleDB, + table: CatalogTable, + ids: readonly string[], +): Promise> { + const unique = [...new Set(ids.filter((id) => id.length > 0))]; + if (unique.length === 0) { + return new Map(); + } + const rows = await db + .select({ id: table.id, code: table.code, name: table.name }) + .from(table) + .where(inArray(table.id, unique)); + return new Map( + rows.map((row) => [row.id, { id: row.id, code: row.code, name: row.name }]), + ); +} + +async function loadCodeMap( + db: DrizzleDB, + table: CodeTable, + ids: readonly string[], +): Promise> { + const unique = [...new Set(ids.filter((id) => id.length > 0))]; + if (unique.length === 0) { + return new Map(); + } + const rows = await db + .select({ id: table.id, code: table.code }) + .from(table) + .where(inArray(table.id, unique)); + return new Map(rows.map((row) => [row.id, { id: row.id, code: row.code }])); +} + +export function loadEmployeeRelationMap(db: DrizzleDB, ids: readonly string[]) { + return loadDefaultMap(db, employees, ids); +} + +export function loadBranchRelationMap(db: DrizzleDB, ids: readonly string[]) { + return loadDefaultMap(db, branches, ids); +} + +export function loadDivisionRelationMap(db: DrizzleDB, ids: readonly string[]) { + return loadDefaultMap(db, divisions, ids); +} + +export function loadCustomerRelationMap(db: DrizzleDB, ids: readonly string[]) { + return loadDefaultMap(db, customers, ids); +} + +export function loadProductRelationMap(db: DrizzleDB, ids: readonly string[]) { + return loadDefaultMap(db, products, ids); +} + +export function loadSalesRequestRelationMap( + db: DrizzleDB, + ids: readonly string[], +) { + return loadCodeMap(db, salesRequests, ids); +} + +export function loadSalesOrderRelationMap( + db: DrizzleDB, + ids: readonly string[], +) { + return loadCodeMap(db, salesOrders, ids); +} + +export function loadPackingSlipRelationMap( + db: DrizzleDB, + ids: readonly string[], +) { + return loadCodeMap(db, packingSlips, ids); +} + +export function loadSalesInvoiceRelationMap( + db: DrizzleDB, + ids: readonly string[], +) { + return loadCodeMap(db, salesInvoices, ids); +} + +export function catalogRelationFromMap( + map: Map, + id: string | null | undefined, +): DefaultRelation | null { + if (!id) { + return null; + } + return map.get(id) ?? null; +} + +export function codeRelationFromMap( + map: Map, + id: string | null | undefined, +): CodeRelation | null { + if (!id) { + return null; + } + return map.get(id) ?? null; +} diff --git a/src/database/load-user-refs.ts b/src/database/load-user-refs.ts new file mode 100644 index 0000000..61f213e --- /dev/null +++ b/src/database/load-user-refs.ts @@ -0,0 +1,47 @@ +import { inArray } from 'drizzle-orm'; +import type { UserRelation } from '../common/http/response'; +import type { DrizzleDB } from './database.module'; +import { users } from './schema'; + +export async function loadUserRelationMap( + db: DrizzleDB, + ids: readonly string[], +): Promise> { + const unique = [...new Set(ids.filter((id) => id.length > 0))]; + if (unique.length === 0) { + return new Map(); + } + const rows = await db + .select({ id: users.id, username: users.username }) + .from(users) + .where(inArray(users.id, unique)); + return new Map( + rows.map((row) => [row.id, { id: row.id, username: row.username }]), + ); +} + +export function userRelationFromMap( + map: Map, + id: string, +): UserRelation { + return map.get(id) ?? { id, username: '' }; +} + +export async function attachAuditUsers< + T extends { createdBy: string; updatedBy: string }, +>( + db: DrizzleDB, + items: T[], +): Promise< + Array +> { + const map = await loadUserRelationMap( + db, + items.flatMap((item) => [item.createdBy, item.updatedBy]), + ); + return items.map((item) => ({ + ...item, + createdByUser: userRelationFromMap(map, item.createdBy), + updatedByUser: userRelationFromMap(map, item.updatedBy), + })); +} diff --git a/src/modules/configuration/branches/branch.ts b/src/modules/configuration/branches/branch.ts index 1df2741..ca150eb 100644 --- a/src/modules/configuration/branches/branch.ts +++ b/src/modules/configuration/branches/branch.ts @@ -82,6 +82,8 @@ export type ListBranchesFilters = { readonly workingHoursStart?: string; readonly workingHoursEnd?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/configuration/branches/branches.repository.ts b/src/modules/configuration/branches/branches.repository.ts index 9321bc8..b23da2c 100644 --- a/src/modules/configuration/branches/branches.repository.ts +++ b/src/modules/configuration/branches/branches.repository.ts @@ -5,7 +5,8 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; import { alias } from 'drizzle-orm/pg-core'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; @@ -24,6 +25,18 @@ import type { UpdateBranchInput, } from './branch'; +const BRANCH_ORDER_COLUMNS = { + id: branches.id, + code: branches.code, + name: branches.name, + phone: branches.phone, + address: branches.address, + nfcId: branches.nfcId, + status: branches.status, + createdAt: branches.createdAt, + updatedAt: branches.updatedAt, +}; + const createdByUsers = alias(users, 'created_by_users'); const updatedByUsers = alias(users, 'updated_by_users'); @@ -52,7 +65,11 @@ export class BranchesRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(branches.code)) + .orderBy( + ...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); diff --git a/src/modules/configuration/branches/branches.service.ts b/src/modules/configuration/branches/branches.service.ts index 951237f..97e1089 100644 --- a/src/modules/configuration/branches/branches.service.ts +++ b/src/modules/configuration/branches/branches.service.ts @@ -4,7 +4,12 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + pickRelation, + pickUserRelation, + DEFAULT_RELATION_FIELDS, + toListPage, +} from '../../../common/http/response'; import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { Status } from '../../../common/value-objects/status/status'; @@ -36,6 +41,8 @@ export type ListBranchesQuery = { readonly workingHoursStart?: string; readonly workingHoursEnd?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -94,6 +101,8 @@ export class BranchesService { workingHoursStart: query.workingHoursStart, workingHoursEnd: query.workingHoursEnd, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -341,31 +350,15 @@ export class BranchesService { workingHoursStart: branch.workingHoursStart, workingHoursEnd: branch.workingHoursEnd, nfcId: branch.nfcId, - division: this.toDivisionItem(branch.division), + division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS), status: branch.status.value, createdAt: branch.createdAt.value, updatedAt: branch.updatedAt.value, - createdBy: this.toUserItem(branch.createdByUser), - updatedBy: this.toUserItem(branch.updatedByUser), + createdBy: pickUserRelation(branch.createdByUser), + updatedBy: pickUserRelation(branch.updatedByUser), }; } - private toDivisionItem( - division: { id: string; code: string; name: string } | null, - ): { id: string; code: string; name: string } | null { - if (division == null) { - return null; - } - return { id: division.id, code: division.code, name: division.name }; - } - - private toUserItem(user: { id: string; username: string }): { - id: string; - username: string; - } { - return { id: user.id, username: user.username }; - } - get visibleFields(): readonly string[] { return VISIBLE_FIELDS; } diff --git a/src/modules/configuration/customers/customer.ts b/src/modules/configuration/customers/customer.ts index e7227a9..c8380d1 100644 --- a/src/modules/configuration/customers/customer.ts +++ b/src/modules/configuration/customers/customer.ts @@ -1,3 +1,4 @@ +import type { UserRelation } from '../../../common/http/response'; 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'; @@ -27,6 +28,8 @@ export type Customer = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type CustomerContactInput = { @@ -79,6 +82,8 @@ export type ListCustomersFilters = { readonly nfcId?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/configuration/customers/customers.repository.spec.ts b/src/modules/configuration/customers/customers.repository.spec.ts index 831de41..3609307 100644 --- a/src/modules/configuration/customers/customers.repository.spec.ts +++ b/src/modules/configuration/customers/customers.repository.spec.ts @@ -67,7 +67,13 @@ describe('CustomersRepository', () => { beforeEach(async () => { jest.clearAllMocks(); - where.mockImplementation(() => ({ limit, orderBy, returning })); + where.mockImplementation(() => + Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), { + limit, + orderBy, + returning, + }), + ); orderBy.mockImplementation(() => ({ limit })); limit.mockImplementation(() => ({ offset })); offset.mockResolvedValue([row]); diff --git a/src/modules/configuration/customers/customers.repository.ts b/src/modules/configuration/customers/customers.repository.ts index 8efff61..36bbe50 100644 --- a/src/modules/configuration/customers/customers.repository.ts +++ b/src/modules/configuration/customers/customers.repository.ts @@ -5,6 +5,8 @@ import { 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'; @@ -26,6 +28,18 @@ import type { 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; @Injectable() @@ -46,12 +60,16 @@ export class CustomersRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(customers.code)) + .orderBy( + ...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [])), + data: await Promise.all(rows.map((row) => this.hydrate(row, []))), total: Number(totalRow?.total ?? 0), }; } @@ -75,7 +93,7 @@ export class CustomersRepository { return null; } const contacts = await this.selectContacts(this.db, id); - return this.toDomain(row, contacts); + return this.hydrate(row, contacts); } async findByCode(code: string): Promise { @@ -89,7 +107,7 @@ export class CustomersRepository { return null; } const contacts = await this.selectContacts(this.db, row.id); - return this.toDomain(row, contacts); + return this.hydrate(row, contacts); } async create(input: CreateCustomerInput): Promise { @@ -104,7 +122,7 @@ export class CustomersRepository { 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); + return this.hydrate(row, contacts); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -170,7 +188,7 @@ export class CustomersRepository { await this.replaceContacts(tx, id, input.contacts); } const contacts = await this.selectContacts(tx, id); - return this.toDomain(row, contacts); + return this.hydrate(row, contacts); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -197,7 +215,7 @@ export class CustomersRepository { throw new NotFoundException('Customer not found'); } const contacts = await this.selectContacts(this.db, id); - return this.toDomain(row, contacts); + return this.hydrate(row, contacts); } async bulkUpdateStatus( @@ -437,10 +455,17 @@ export class CustomersRepository { }; } - private toDomain( + private async hydrate( row: CustomerRow, contactRows: CustomerContactRow[], - ): Customer { + ): Promise { + 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, diff --git a/src/modules/configuration/customers/customers.service.spec.ts b/src/modules/configuration/customers/customers.service.spec.ts index d7361bc..c8dd645 100644 --- a/src/modules/configuration/customers/customers.service.spec.ts +++ b/src/modules/configuration/customers/customers.service.spec.ts @@ -52,6 +52,8 @@ describe('CustomersService', () => { createdAt: now, updatedAt: now, createdBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, updatedBy: 'user-1', }; diff --git a/src/modules/configuration/customers/customers.service.ts b/src/modules/configuration/customers/customers.service.ts index 702ff08..6ac3889 100644 --- a/src/modules/configuration/customers/customers.service.ts +++ b/src/modules/configuration/customers/customers.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { pickUserRelation, toListPage } from '../../../common/http/response'; import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { Status } from '../../../common/value-objects/status/status'; @@ -38,6 +38,8 @@ export type ListCustomersQuery = { readonly nfcId?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -85,6 +87,8 @@ export class CustomersService { nfcId: query.nfcId, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -350,8 +354,8 @@ export class CustomersService { status: customer.status.value, createdAt: customer.createdAt.value, updatedAt: customer.updatedAt.value, - createdBy: customer.createdBy, - updatedBy: customer.updatedBy, + createdBy: pickUserRelation(customer.createdByUser), + updatedBy: pickUserRelation(customer.updatedByUser), }; } diff --git a/src/modules/configuration/customers/dto/customer.dto.ts b/src/modules/configuration/customers/dto/customer.dto.ts index ca3875c..dbf0aeb 100644 --- a/src/modules/configuration/customers/dto/customer.dto.ts +++ b/src/modules/configuration/customers/dto/customer.dto.ts @@ -15,7 +15,10 @@ import { Min, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { CONTACT_JOB_TITLE_MAX_LENGTH, @@ -347,9 +350,9 @@ export class CustomerDto { @ApiProperty({ description: 'Unix ms' }) updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/configuration/divisions/division.ts b/src/modules/configuration/divisions/division.ts index 480ca63..2f1c280 100644 --- a/src/modules/configuration/divisions/division.ts +++ b/src/modules/configuration/divisions/division.ts @@ -1,3 +1,4 @@ +import type { UserRelation } from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; @@ -10,6 +11,8 @@ export type Division = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type CreateDivisionInput = { @@ -30,6 +33,8 @@ export type ListDivisionsFilters = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/configuration/divisions/divisions.repository.spec.ts b/src/modules/configuration/divisions/divisions.repository.spec.ts index 2985f3b..242e142 100644 --- a/src/modules/configuration/divisions/divisions.repository.spec.ts +++ b/src/modules/configuration/divisions/divisions.repository.spec.ts @@ -43,7 +43,13 @@ describe('DivisionsRepository', () => { beforeEach(async () => { jest.clearAllMocks(); - where.mockImplementation(() => ({ limit, orderBy })); + where.mockImplementation(() => + Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), { + limit, + orderBy, + returning, + }), + ); orderBy.mockImplementation(() => ({ limit })); limit.mockImplementation(() => ({ offset })); offset.mockResolvedValue([row]); @@ -59,7 +65,6 @@ describe('DivisionsRepository', () => { update.mockReturnValue({ set }); del.mockReturnValue({ where }); returning.mockResolvedValue([row]); - where.mockImplementation(() => ({ limit, orderBy, returning })); const moduleRef: TestingModule = await Test.createTestingModule({ providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }], diff --git a/src/modules/configuration/divisions/divisions.repository.ts b/src/modules/configuration/divisions/divisions.repository.ts index e58aa75..bcac723 100644 --- a/src/modules/configuration/divisions/divisions.repository.ts +++ b/src/modules/configuration/divisions/divisions.repository.ts @@ -4,10 +4,12 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; +import { attachAuditUsers } from '../../../database/load-user-refs'; import { divisions, type DivisionRow } from '../../../database/schema'; import type { CreateDivisionInput, @@ -16,6 +18,15 @@ import type { UpdateDivisionInput, } from './division'; +const DIVISION_ORDER_COLUMNS = { + id: divisions.id, + name: divisions.name, + code: divisions.code, + status: divisions.status, + createdAt: divisions.createdAt, + updatedAt: divisions.updatedAt, +}; + @Injectable() export class DivisionsRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} @@ -34,12 +45,16 @@ export class DivisionsRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(divisions.code)) + .orderBy( + ...toOrderClauses(DIVISION_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row)), + data: await this.hydrate(rows), total: Number(totalRow?.total ?? 0), }; } @@ -59,7 +74,7 @@ export class DivisionsRepository { .where(eq(divisions.id, id)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? this.hydrateOne(row) : null; } async findByCode(code: string): Promise { @@ -69,7 +84,7 @@ export class DivisionsRepository { .where(eq(divisions.code, code)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? this.hydrateOne(row) : null; } async create(input: CreateDivisionInput): Promise { @@ -89,7 +104,7 @@ export class DivisionsRepository { }) .returning(); const row = inserted[0]; - return this.toDomain(row); + return this.hydrateOne(row); } catch (error) { this.rethrowUniqueViolation(error); } @@ -139,7 +154,7 @@ export class DivisionsRepository { .where(eq(divisions.id, id)) .returning(); const row = updated[0]; - return this.toDomain(row); + return this.hydrateOne(row); } catch (error) { this.rethrowUniqueViolation(error); } @@ -164,7 +179,7 @@ export class DivisionsRepository { if (!row) { throw new NotFoundException('Division not found'); } - return this.toDomain(row); + return this.hydrateOne(row); } async bulkUpdateStatus( @@ -243,17 +258,25 @@ export class DivisionsRepository { return parts.length === 1 ? parts[0] : and(...parts); } - private toDomain(row: DivisionRow): Division { - return { - id: row.id, - name: row.name, - code: row.code, - status: Status.create(row.status), - createdAt: DateTime.fromUnixMs(row.createdAt), - updatedAt: DateTime.fromUnixMs(row.updatedAt), - createdBy: row.createdBy, - updatedBy: row.updatedBy, - }; + private async hydrate(rows: DivisionRow[]): Promise { + return attachAuditUsers( + this.db, + rows.map((row) => ({ + id: row.id, + name: row.name, + code: row.code, + status: Status.create(row.status), + createdAt: DateTime.fromUnixMs(row.createdAt), + updatedAt: DateTime.fromUnixMs(row.updatedAt), + createdBy: row.createdBy, + updatedBy: row.updatedBy, + })), + ); + } + + private async hydrateOne(row: DivisionRow): Promise { + const [item] = await this.hydrate([row]); + return item; } private rethrowUniqueViolation(error: unknown): never { diff --git a/src/modules/configuration/divisions/divisions.service.spec.ts b/src/modules/configuration/divisions/divisions.service.spec.ts index f140eee..4993497 100644 --- a/src/modules/configuration/divisions/divisions.service.spec.ts +++ b/src/modules/configuration/divisions/divisions.service.spec.ts @@ -33,6 +33,8 @@ describe('DivisionsService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; beforeEach(async () => { @@ -68,6 +70,7 @@ describe('DivisionsService', () => { code: 'FIN', status: 'draft', createdAt: now.value, + createdBy: { id: 'user-1', username: 'admin' }, }); expect(service.visibleFields).toContain('status'); }); diff --git a/src/modules/configuration/divisions/divisions.service.ts b/src/modules/configuration/divisions/divisions.service.ts index eb34c7f..7304d44 100644 --- a/src/modules/configuration/divisions/divisions.service.ts +++ b/src/modules/configuration/divisions/divisions.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { pickUserRelation, toListPage } from '../../../common/http/response'; import { Status } from '../../../common/value-objects/status/status'; import type { CreateDivisionInput, @@ -19,6 +19,8 @@ export type ListDivisionsQuery = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -48,6 +50,8 @@ export class DivisionsService { code: query.code, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -221,8 +225,8 @@ export class DivisionsService { status: division.status.value, createdAt: division.createdAt.value, updatedAt: division.updatedAt.value, - createdBy: division.createdBy, - updatedBy: division.updatedBy, + createdBy: pickUserRelation(division.createdByUser), + updatedBy: pickUserRelation(division.updatedByUser), }; } diff --git a/src/modules/configuration/divisions/dto/division.dto.ts b/src/modules/configuration/divisions/dto/division.dto.ts index 2eff5cc..a3bdb24 100644 --- a/src/modules/configuration/divisions/dto/division.dto.ts +++ b/src/modules/configuration/divisions/dto/division.dto.ts @@ -10,7 +10,10 @@ import { Matches, MaxLength, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { DIVISION_CODE_MAX_LENGTH, @@ -19,6 +22,15 @@ import { DIVISION_NAME_PATTERN, } from '../division-fields'; +export const DIVISION_ORDER_FIELDS = [ + 'id', + 'name', + 'code', + 'status', + 'createdAt', + 'updatedAt', +] as const; + export class CreateDivisionDto { @ApiProperty({ example: 'Human Resources', @@ -138,9 +150,9 @@ export class DivisionDto { @ApiProperty({ description: 'Unix ms' }) updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/configuration/employees/dto/employee.dto.ts b/src/modules/configuration/employees/dto/employee.dto.ts index 42ab150..31d7218 100644 --- a/src/modules/configuration/employees/dto/employee.dto.ts +++ b/src/modules/configuration/employees/dto/employee.dto.ts @@ -247,11 +247,11 @@ export class EmployeeDto { @ApiProperty({ description: 'Unix ms' }) updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; @ApiPropertyOptional({ type: UserRelationDto, nullable: true }) user!: UserRelationDto | null; diff --git a/src/modules/configuration/employees/employee.ts b/src/modules/configuration/employees/employee.ts index 89b9652..bdc211b 100644 --- a/src/modules/configuration/employees/employee.ts +++ b/src/modules/configuration/employees/employee.ts @@ -1,3 +1,4 @@ +import type { UserRelation } from '../../../common/http/response'; 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'; @@ -14,6 +15,8 @@ export type Employee = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; readonly userId: string | null; readonly user: { readonly id: string; readonly username: string } | null; }; @@ -51,6 +54,8 @@ export type ListEmployeesFilters = { readonly status?: string; readonly userId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/configuration/employees/employees.repository.spec.ts b/src/modules/configuration/employees/employees.repository.spec.ts index 45cc1c8..097ba71 100644 --- a/src/modules/configuration/employees/employees.repository.spec.ts +++ b/src/modules/configuration/employees/employees.repository.spec.ts @@ -57,7 +57,13 @@ describe('EmployeesRepository', () => { beforeEach(async () => { jest.clearAllMocks(); - where.mockImplementation(() => ({ limit, orderBy, returning })); + where.mockImplementation(() => + Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), { + limit, + orderBy, + returning, + }), + ); orderBy.mockImplementation(() => ({ limit })); limit.mockImplementation(() => ({ offset })); offset.mockResolvedValue([row]); @@ -123,14 +129,14 @@ describe('EmployeesRepository', () => { .mockImplementationOnce(() => ({ from: () => ({ $dynamic: () => ({ - where: () => ({ - orderBy: () => ({ - limit: () => ({ - offset: () => Promise.resolve([row]), - }), + where: () => ({ + orderBy: () => ({ + limit: () => ({ + offset: () => Promise.resolve([row]), }), }), }), + }), }), })); diff --git a/src/modules/configuration/employees/employees.repository.ts b/src/modules/configuration/employees/employees.repository.ts index 3dd9010..67de915 100644 --- a/src/modules/configuration/employees/employees.repository.ts +++ b/src/modules/configuration/employees/employees.repository.ts @@ -4,7 +4,9 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, 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'; @@ -19,6 +21,17 @@ import type { UpdateEmployeeInput, } from './employee'; +const EMPLOYEE_ORDER_COLUMNS = { + id: employees.id, + code: employees.code, + name: employees.name, + phone: employees.phone, + position: employees.position, + status: employees.status, + createdAt: employees.createdAt, + updatedAt: employees.updatedAt, +}; + @Injectable() export class EmployeesRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} @@ -37,16 +50,21 @@ export class EmployeesRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(employees.code)) + .orderBy( + ...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); - return { - data: await Promise.all( - rows.map(async (row) => - this.toDomain(row, await this.loadAssignedUser(row.userId)), - ), + const mapped = await Promise.all( + rows.map(async (row) => + this.hydrateOne(row, await this.loadAssignedUser(row.userId)), ), + ); + return { + data: mapped, total: Number(totalRow?.total ?? 0), }; } @@ -67,7 +85,7 @@ export class EmployeesRepository { .limit(1); const row = rows[0]; return row - ? this.toDomain(row, await this.loadAssignedUser(row.userId)) + ? this.hydrateOne(row, await this.loadAssignedUser(row.userId)) : null; } @@ -79,7 +97,7 @@ export class EmployeesRepository { .limit(1); const row = rows[0]; return row - ? this.toDomain(row, await this.loadAssignedUser(row.userId)) + ? this.hydrateOne(row, await this.loadAssignedUser(row.userId)) : null; } @@ -91,7 +109,7 @@ export class EmployeesRepository { .limit(1); const row = rows[0]; return row - ? this.toDomain(row, await this.loadAssignedUser(row.userId)) + ? this.hydrateOne(row, await this.loadAssignedUser(row.userId)) : null; } @@ -104,7 +122,7 @@ export class EmployeesRepository { .values(this.toInsertValues(input, status, now, input.userId)) .returning(); const row = inserted[0]; - return this.toDomain(row, await this.loadAssignedUser(row.userId)); + return this.hydrateOne(row, await this.loadAssignedUser(row.userId)); } catch (error) { this.rethrowUniqueViolation(error); } @@ -153,7 +171,7 @@ export class EmployeesRepository { .where(eq(employees.id, id)) .returning(); const row = updated[0]; - return this.toDomain(row, await this.loadAssignedUser(row.userId)); + return this.hydrateOne(row, await this.loadAssignedUser(row.userId)); } catch (error) { this.rethrowUniqueViolation(error); } @@ -178,7 +196,7 @@ export class EmployeesRepository { if (!row) { throw new NotFoundException('Employee not found'); } - return this.toDomain(row, await this.loadAssignedUser(row.userId)); + return this.hydrateOne(row, await this.loadAssignedUser(row.userId)); } async bulkUpdateStatus( @@ -308,7 +326,7 @@ export class EmployeesRepository { private toDomain( row: EmployeeRow, user: { id: string; username: string } | null, - ): Employee { + ) { return { id: row.id, code: row.code, @@ -325,6 +343,14 @@ export class EmployeesRepository { }; } + private async hydrateOne( + row: EmployeeRow, + user: { id: string; username: string } | null, + ): Promise { + const [item] = await attachAuditUsers(this.db, [this.toDomain(row, user)]); + return item; + } + private rethrowUniqueViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { diff --git a/src/modules/configuration/employees/employees.service.spec.ts b/src/modules/configuration/employees/employees.service.spec.ts index f3f30dc..837679f 100644 --- a/src/modules/configuration/employees/employees.service.spec.ts +++ b/src/modules/configuration/employees/employees.service.spec.ts @@ -45,6 +45,8 @@ describe('EmployeesService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, userId: null, user: null, }; diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index 9da5186..97c6438 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -5,7 +5,12 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + pickRelation, + pickUserRelation, + USER_RELATION_FIELDS, + toListPage, +} from '../../../common/http/response'; import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { Status } from '../../../common/value-objects/status/status'; @@ -33,6 +38,8 @@ export type ListEmployeesQuery = { readonly status?: string; readonly userId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -73,6 +80,8 @@ export class EmployeesService { status: query.status, userId: query.userId, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -284,9 +293,9 @@ export class EmployeesService { status: employee.status.value, createdAt: employee.createdAt.value, updatedAt: employee.updatedAt.value, - createdBy: employee.createdBy, - updatedBy: employee.updatedBy, - user: employee.user, + createdBy: pickUserRelation(employee.createdByUser), + updatedBy: pickUserRelation(employee.updatedByUser), + user: pickRelation(employee.user, USER_RELATION_FIELDS), }; } diff --git a/src/modules/configuration/products/dto/product.dto.ts b/src/modules/configuration/products/dto/product.dto.ts index 1b006b4..e81bc31 100644 --- a/src/modules/configuration/products/dto/product.dto.ts +++ b/src/modules/configuration/products/dto/product.dto.ts @@ -10,7 +10,10 @@ import { Matches, MaxLength, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { PRODUCT_BRAND_MAX_LENGTH, @@ -200,9 +203,9 @@ export class ProductDto { @ApiProperty({ description: 'Unix ms' }) updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/configuration/products/product.ts b/src/modules/configuration/products/product.ts index 5b2fe4d..8b0e195 100644 --- a/src/modules/configuration/products/product.ts +++ b/src/modules/configuration/products/product.ts @@ -1,3 +1,4 @@ +import type { UserRelation } from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -14,6 +15,8 @@ export type Product = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type CreateProductInput = { @@ -42,6 +45,8 @@ export type ListProductsFilters = { readonly brand?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/configuration/products/products.repository.spec.ts b/src/modules/configuration/products/products.repository.spec.ts index 5e7af7c..bc84e93 100644 --- a/src/modules/configuration/products/products.repository.spec.ts +++ b/src/modules/configuration/products/products.repository.spec.ts @@ -56,7 +56,13 @@ describe('ProductsRepository', () => { beforeEach(async () => { jest.clearAllMocks(); - where.mockImplementation(() => ({ limit, orderBy, returning })); + where.mockImplementation(() => + Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), { + limit, + orderBy, + returning, + }), + ); orderBy.mockImplementation(() => ({ limit })); limit.mockImplementation(() => ({ offset })); offset.mockResolvedValue([row]); diff --git a/src/modules/configuration/products/products.repository.ts b/src/modules/configuration/products/products.repository.ts index 80d2375..cbd43db 100644 --- a/src/modules/configuration/products/products.repository.ts +++ b/src/modules/configuration/products/products.repository.ts @@ -4,7 +4,9 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, 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 { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -17,6 +19,17 @@ import type { UpdateProductInput, } from './product'; +const PRODUCT_ORDER_COLUMNS = { + id: products.id, + code: products.code, + name: products.name, + unit: products.unit, + brand: products.brand, + status: products.status, + createdAt: products.createdAt, + updatedAt: products.updatedAt, +}; + @Injectable() export class ProductsRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} @@ -35,12 +48,16 @@ export class ProductsRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(products.code)) + .orderBy( + ...toOrderClauses(PRODUCT_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row)), + data: await this.hydrate(rows), total: Number(totalRow?.total ?? 0), }; } @@ -60,7 +77,7 @@ export class ProductsRepository { .where(eq(products.id, id)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? await this.hydrateOne(row) : null; } async findByCode(code: string): Promise { @@ -70,7 +87,7 @@ export class ProductsRepository { .where(eq(products.code, code)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row) : null; + return row ? await this.hydrateOne(row) : null; } async create(input: CreateProductInput): Promise { @@ -82,7 +99,7 @@ export class ProductsRepository { .values(this.toInsertValues(input, status, now, input.userId)) .returning(); const row = inserted[0]; - return this.toDomain(row); + return await this.hydrateOne(row); } catch (error) { this.rethrowConstraintViolation(error); } @@ -132,7 +149,7 @@ export class ProductsRepository { .where(eq(products.id, id)) .returning(); const row = updated[0]; - return this.toDomain(row); + return await this.hydrateOne(row); } catch (error) { this.rethrowConstraintViolation(error); } @@ -157,7 +174,7 @@ export class ProductsRepository { if (!row) { throw new NotFoundException('Product not found'); } - return this.toDomain(row); + return await this.hydrateOne(row); } async bulkUpdateStatus( @@ -262,7 +279,7 @@ export class ProductsRepository { }; } - private toDomain(row: ProductRow): Product { + private toDomain(row: ProductRow) { return { id: row.id, code: row.code, @@ -278,6 +295,18 @@ export class ProductsRepository { }; } + private async hydrate(rows: ProductRow[]): Promise { + return attachAuditUsers( + this.db, + rows.map((row) => this.toDomain(row)), + ); + } + + private async hydrateOne(row: ProductRow): Promise { + const [item] = await this.hydrate([row]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { diff --git a/src/modules/configuration/products/products.service.spec.ts b/src/modules/configuration/products/products.service.spec.ts index 493d62d..ebe9ce8 100644 --- a/src/modules/configuration/products/products.service.spec.ts +++ b/src/modules/configuration/products/products.service.spec.ts @@ -36,6 +36,8 @@ describe('ProductsService', () => { createdAt: now, updatedAt: now, createdBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, updatedBy: 'user-1', }; diff --git a/src/modules/configuration/products/products.service.ts b/src/modules/configuration/products/products.service.ts index 07b133b..f5df0ba 100644 --- a/src/modules/configuration/products/products.service.ts +++ b/src/modules/configuration/products/products.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { pickUserRelation, toListPage } from '../../../common/http/response'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error'; import { Status } from '../../../common/value-objects/status/status'; @@ -29,6 +29,8 @@ export type ListProductsQuery = { readonly brand?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -65,6 +67,8 @@ export class ProductsService { brand: query.brand, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -240,8 +244,8 @@ export class ProductsService { status: product.status.value, createdAt: product.createdAt.value, updatedAt: product.updatedAt.value, - createdBy: product.createdBy, - updatedBy: product.updatedBy, + createdBy: pickUserRelation(product.createdByUser), + updatedBy: pickUserRelation(product.updatedByUser), }; } diff --git a/src/modules/field/cycles/cycle.ts b/src/modules/field/cycles/cycle.ts index 8ab5c5c..8b3250f 100644 --- a/src/modules/field/cycles/cycle.ts +++ b/src/modules/field/cycles/cycle.ts @@ -1,3 +1,7 @@ +import type { + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; @@ -7,6 +11,7 @@ import type { WeekdaysInput } from '../shared/field-fields'; export type CycleDestination = { readonly id: string; readonly customerId: string; + readonly customer: DefaultRelation | null; readonly sortOrder: number; }; @@ -15,6 +20,8 @@ export type CycleWeekday = { readonly weekday: WeekdayName; readonly startBranchId: string; readonly endBranchId: string; + readonly startBranch: DefaultRelation | null; + readonly endBranch: DefaultRelation | null; readonly routeGeometry: RouteGeometry; readonly destinations: readonly CycleDestination[]; }; @@ -22,6 +29,7 @@ export type CycleWeekday = { export type Cycle = { readonly id: string; readonly employeeId: string; + readonly employee: DefaultRelation | null; readonly purpose: FieldPurpose; readonly cycleNumber: number; readonly weekdays: readonly CycleWeekday[]; @@ -30,6 +38,8 @@ export type Cycle = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type CreateCycleInput = { @@ -55,6 +65,8 @@ export type ListCyclesFilters = { readonly cycleNumber?: number; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly purposes?: readonly string[]; readonly limit: number; readonly offset: number; diff --git a/src/modules/field/cycles/cycles.repository.ts b/src/modules/field/cycles/cycles.repository.ts index d029dfb..f56749d 100644 --- a/src/modules/field/cycles/cycles.repository.ts +++ b/src/modules/field/cycles/cycles.repository.ts @@ -16,6 +16,14 @@ import { sql, SQL, } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + loadBranchRelationMap, + loadCustomerRelationMap, + loadEmployeeRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachAuditUsers } from '../../../database/load-user-refs'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; @@ -30,6 +38,16 @@ import { import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle'; +const CYCLE_ORDER_COLUMNS = { + id: cycles.id, + employeeId: cycles.employeeId, + purpose: cycles.purpose, + cycleNumber: cycles.cycleNumber, + status: cycles.status, + createdAt: cycles.createdAt, + updatedAt: cycles.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -48,11 +66,15 @@ export class CyclesRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(cycles.cycleNumber)) + .orderBy( + ...toOrderClauses(CYCLE_ORDER_COLUMNS, filters, [ + { column: 'cycleNumber', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [], [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -77,7 +99,7 @@ export class CyclesRepository { this.db, weekdays.map((weekday) => weekday.id), ); - return this.toDomain(row, weekdays, destinations); + return this.hydrateOne(row, weekdays, destinations); } async findLiveByKey( @@ -101,7 +123,7 @@ export class CyclesRepository { .where(and(...parts)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row, [], []) : null; + return row ? this.hydrateOne(row, [], []) : null; } async listActiveByEmployeePurpose( @@ -126,7 +148,7 @@ export class CyclesRepository { this.db, weekdays.map((weekday) => weekday.id), ); - result.push(this.toDomain(row, weekdays, destinations)); + result.push(await this.hydrateOne(row, weekdays, destinations)); } return result; } @@ -291,7 +313,7 @@ export class CyclesRepository { executor, weekdays.map((weekday) => weekday.id), ); - return this.toDomain(row, weekdays, destinations); + return this.hydrateOne(row, weekdays, destinations); } private async replaceWeekdays( @@ -406,11 +428,14 @@ export class CyclesRepository { weekday: weekday.weekday as WeekdayName, startBranchId: weekday.startBranchId, endBranchId: weekday.endBranchId, + startBranch: null, + endBranch: null, routeGeometry: weekday.routeGeometry, destinations: (destinationsByWeekday.get(weekday.id) ?? []).map( (destination) => ({ id: destination.id, customerId: destination.customerId, + customer: null, sortOrder: destination.sortOrder, }), ), @@ -420,9 +445,59 @@ export class CyclesRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + employee: null, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: Cycle[]): Promise { + const withAudit = await attachAuditUsers(this.db, items); + const branchIds = withAudit.flatMap((item) => + item.weekdays.flatMap((weekday) => [ + weekday.startBranchId, + weekday.endBranchId, + ]), + ); + const customerIds = withAudit.flatMap((item) => + item.weekdays.flatMap((weekday) => + weekday.destinations.map((destination) => destination.customerId), + ), + ); + const [employees, branches, customers] = await Promise.all([ + loadEmployeeRelationMap( + this.db, + withAudit.map((item) => item.employeeId), + ), + loadBranchRelationMap(this.db, branchIds), + loadCustomerRelationMap(this.db, customerIds), + ]); + return withAudit.map((item) => ({ + ...item, + employee: catalogRelationFromMap(employees, item.employeeId), + weekdays: item.weekdays.map((weekday) => ({ + ...weekday, + startBranch: catalogRelationFromMap(branches, weekday.startBranchId), + endBranch: catalogRelationFromMap(branches, weekday.endBranchId), + destinations: weekday.destinations.map((destination) => ({ + ...destination, + customer: catalogRelationFromMap(customers, destination.customerId), + })), + })), + })); + } + + private async hydrateOne( + row: CycleRow, + weekdays: CycleWeekdayRow[], + destinations: CycleDestinationRow[], + ): Promise { + const [item] = await this.hydrate([ + this.toDomain(row, weekdays, destinations), + ]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { diff --git a/src/modules/field/cycles/cycles.service.spec.ts b/src/modules/field/cycles/cycles.service.spec.ts index b3805ae..458cf88 100644 --- a/src/modules/field/cycles/cycles.service.spec.ts +++ b/src/modules/field/cycles/cycles.service.spec.ts @@ -62,8 +62,17 @@ describe('CyclesService', () => { weekday: 'monday', startBranchId: 'br-1', endBranchId: 'br-2', + startBranch: { id: 'br-1', code: 'B1', name: 'Start' }, + endBranch: { id: 'br-2', code: 'B2', name: 'End' }, routeGeometry: geometry, - destinations: [{ id: 'dest-1', customerId: 'cus-1', sortOrder: 0 }], + destinations: [ + { + id: 'dest-1', + customerId: 'cus-1', + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + sortOrder: 0, + }, + ], }, ], status: Status.create('draft'), @@ -71,6 +80,9 @@ describe('CyclesService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const located = { id: 'x', latitude: -6.2, longitude: 106.8 }; diff --git a/src/modules/field/cycles/cycles.service.ts b/src/modules/field/cycles/cycles.service.ts index 7c816d0..dc7d418 100644 --- a/src/modules/field/cycles/cycles.service.ts +++ b/src/modules/field/cycles/cycles.service.ts @@ -7,7 +7,12 @@ import { } from '@nestjs/common'; import type { AuthUser } from '../../../common/auth/auth-user'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error'; import { Status } from '../../../common/value-objects/status/status'; import { BranchesService } from '../../configuration/branches/branches.service'; @@ -40,6 +45,8 @@ export type ListCyclesQuery = { readonly cycleNumber?: number; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -93,6 +100,8 @@ export class CyclesService { cycleNumber: query.cycleNumber, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, purposes: query.purpose ? undefined : purposes, limit: page.limit, offset: page.offset, @@ -237,26 +246,26 @@ export class CyclesService { toItem(cycle: Cycle) { return { id: cycle.id, - employeeId: cycle.employeeId, + employee: pickRelation(cycle.employee, DEFAULT_RELATION_FIELDS), purpose: cycle.purpose, cycleNumber: cycle.cycleNumber, weekdays: cycle.weekdays.map((weekday) => ({ id: weekday.id, weekday: weekday.weekday, - startBranchId: weekday.startBranchId, - endBranchId: weekday.endBranchId, + startBranch: pickRelation(weekday.startBranch, DEFAULT_RELATION_FIELDS), + endBranch: pickRelation(weekday.endBranch, DEFAULT_RELATION_FIELDS), routeGeometry: weekday.routeGeometry, destinations: weekday.destinations.map((destination) => ({ id: destination.id, - customerId: destination.customerId, + customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS), sortOrder: destination.sortOrder, })), })), status: cycle.status.value, createdAt: cycle.createdAt.value, updatedAt: cycle.updatedAt.value, - createdBy: cycle.createdBy, - updatedBy: cycle.updatedBy, + createdBy: pickUserRelation(cycle.createdByUser), + updatedBy: pickUserRelation(cycle.updatedByUser), }; } diff --git a/src/modules/field/cycles/dto/cycle.dto.ts b/src/modules/field/cycles/dto/cycle.dto.ts index a426739..b2de821 100644 --- a/src/modules/field/cycles/dto/cycle.dto.ts +++ b/src/modules/field/cycles/dto/cycle.dto.ts @@ -13,7 +13,11 @@ import { Min, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose'; import { RouteGeometryDto } from '../../shared/route-geometry.dto'; @@ -143,8 +147,8 @@ export class CycleDestinationDto { @ApiProperty({ format: 'uuid' }) id!: string; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiProperty() sortOrder!: number; @@ -157,11 +161,11 @@ export class CycleWeekdayDto { @ApiProperty({ enum: WEEKDAY_NAMES }) weekday!: string; - @ApiProperty({ format: 'uuid' }) - startBranchId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + startBranch!: DefaultRelationDto | null; - @ApiProperty({ format: 'uuid' }) - endBranchId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + endBranch!: DefaultRelationDto | null; @ApiProperty({ type: RouteGeometryDto }) routeGeometry!: RouteGeometryDto; @@ -174,8 +178,8 @@ export class CycleDto { @ApiProperty({ format: 'uuid' }) id!: string; - @ApiProperty({ format: 'uuid' }) - employeeId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + employee!: DefaultRelationDto | null; @ApiProperty({ enum: FIELD_PURPOSES }) purpose!: string; @@ -195,11 +199,11 @@ export class CycleDto { @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } void ValidateNested; diff --git a/src/modules/field/plans/dto/plan.dto.ts b/src/modules/field/plans/dto/plan.dto.ts index ca1b1c0..a281aac 100644 --- a/src/modules/field/plans/dto/plan.dto.ts +++ b/src/modules/field/plans/dto/plan.dto.ts @@ -10,7 +10,12 @@ import { IsUUID, Matches, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + CodeRelationDto, + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { CORE_STATUSES } from '../../../../common/value-objects/status/status'; import { FIELD_PURPOSES } from '../../shared/field-purpose'; import { RouteGeometryDto } from '../../shared/route-geometry.dto'; @@ -199,8 +204,8 @@ export class PlanDestinationDto { @ApiProperty({ format: 'uuid' }) id!: string; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiProperty() sortOrder!: number; @@ -210,8 +215,8 @@ export class PlanDto { @ApiProperty({ format: 'uuid' }) id!: string; - @ApiProperty({ format: 'uuid' }) - employeeId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + employee!: DefaultRelationDto | null; @ApiProperty({ enum: FIELD_PURPOSES }) purpose!: string; @@ -219,11 +224,11 @@ export class PlanDto { @ApiProperty() date!: number; - @ApiProperty({ format: 'uuid' }) - startBranchId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + startBranch!: DefaultRelationDto | null; - @ApiProperty({ format: 'uuid' }) - endBranchId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + endBranch!: DefaultRelationDto | null; @ApiProperty({ type: RouteGeometryDto }) routeGeometry!: RouteGeometryDto; @@ -231,11 +236,11 @@ export class PlanDto { @ApiProperty({ type: [PlanDestinationDto] }) destinations!: PlanDestinationDto[]; - @ApiProperty({ type: [String] }) - invoiceIds!: string[]; + @ApiProperty({ type: [CodeRelationDto] }) + invoices!: CodeRelationDto[]; - @ApiProperty({ type: [String] }) - packingSlipIds!: string[]; + @ApiProperty({ type: [CodeRelationDto] }) + packingSlips!: CodeRelationDto[]; @ApiProperty({ enum: CORE_STATUSES }) status!: string; @@ -246,11 +251,11 @@ export class PlanDto { @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } void Type; diff --git a/src/modules/field/plans/plan.ts b/src/modules/field/plans/plan.ts index 187097f..fd31c0e 100644 --- a/src/modules/field/plans/plan.ts +++ b/src/modules/field/plans/plan.ts @@ -1,3 +1,8 @@ +import type { + CodeRelation, + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import type { FieldPurpose } from '../shared/field-purpose'; @@ -6,25 +11,33 @@ import type { RouteGeometry } from '../shared/route-line-string'; export type PlanDestination = { readonly id: string; readonly customerId: string; + readonly customer: DefaultRelation | null; readonly sortOrder: number; }; export type Plan = { readonly id: string; readonly employeeId: string; + readonly employee: DefaultRelation | null; readonly purpose: FieldPurpose; readonly date: DateTime; readonly startBranchId: string; readonly endBranchId: string; + readonly startBranch: DefaultRelation | null; + readonly endBranch: DefaultRelation | null; readonly routeGeometry: RouteGeometry; readonly destinations: readonly PlanDestination[]; readonly invoiceIds: readonly string[]; readonly packingSlipIds: readonly string[]; + readonly invoices: readonly CodeRelation[]; + readonly packingSlips: readonly CodeRelation[]; readonly status: Status; readonly createdAt: DateTime; readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type PersistablePlanDestination = { @@ -37,6 +50,8 @@ export type ListPlansFilters = { readonly date?: number; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly purposes?: readonly string[]; readonly limit: number; readonly offset: number; diff --git a/src/modules/field/plans/plans.repository.ts b/src/modules/field/plans/plans.repository.ts index 469898d..9ad30b2 100644 --- a/src/modules/field/plans/plans.repository.ts +++ b/src/modules/field/plans/plans.repository.ts @@ -16,6 +16,16 @@ import { sql, SQL, } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + loadBranchRelationMap, + loadCustomerRelationMap, + loadEmployeeRelationMap, + loadPackingSlipRelationMap, + loadSalesInvoiceRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachAuditUsers } from '../../../database/load-user-refs'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; @@ -33,6 +43,16 @@ import type { FieldPurpose } from '../shared/field-purpose'; import type { RouteGeometry } from '../shared/route-line-string'; import type { ListPlansFilters, Plan } from './plan'; +const PLAN_ORDER_COLUMNS = { + id: plans.id, + employeeId: plans.employeeId, + purpose: plans.purpose, + date: plans.date, + status: plans.status, + createdAt: plans.createdAt, + updatedAt: plans.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -51,11 +71,17 @@ export class PlansRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(plans.date)) + .orderBy( + ...toOrderClauses(PLAN_ORDER_COLUMNS, filters, [ + { column: 'date', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [], [], [])), + data: await this.hydrate( + rows.map((row) => this.toDomain(row, [], [], [])), + ), total: Number(totalRows[0]?.total ?? 0), }; } @@ -99,7 +125,7 @@ export class PlansRepository { .where(and(...parts)) .limit(1); const row = rows[0]; - return row ? this.toDomain(row, [], [], []) : null; + return row ? this.hydrateOne(row, [], [], []) : null; } async create(input: { @@ -327,7 +353,7 @@ export class PlansRepository { .select() .from(planPackingSlips) .where(eq(planPackingSlips.planId, row.id)); - return this.toDomain(row, destinations, invoices, packingSlips); + return this.hydrateOne(row, destinations, invoices, packingSlips); } private async replaceChildren( @@ -423,21 +449,86 @@ export class PlansRepository { startBranchId: row.startBranchId, endBranchId: row.endBranchId, routeGeometry: row.routeGeometry, + startBranch: null, + endBranch: null, + employee: null, destinations: destinationRows.map((destination) => ({ id: destination.id, customerId: destination.customerId, + customer: null, sortOrder: destination.sortOrder, })), - invoiceIds: invoiceRows.map((row) => row.invoiceId), - packingSlipIds: packingSlipRows.map((row) => row.packingSlipId), + invoiceIds: invoiceRows.map((invoice) => invoice.invoiceId), + packingSlipIds: packingSlipRows.map((slip) => slip.packingSlipId), + invoices: [], + packingSlips: [], status: Status.create(row.status), createdAt: DateTime.fromUnixMs(row.createdAt), updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: Plan[]): Promise { + const withAudit = await attachAuditUsers(this.db, items); + const [employees, branches, customers, invoices, packing] = + await Promise.all([ + loadEmployeeRelationMap( + this.db, + withAudit.map((item) => item.employeeId), + ), + loadBranchRelationMap( + this.db, + withAudit.flatMap((item) => [item.startBranchId, item.endBranchId]), + ), + loadCustomerRelationMap( + this.db, + withAudit.flatMap((item) => + item.destinations.map((destination) => destination.customerId), + ), + ), + loadSalesInvoiceRelationMap( + this.db, + withAudit.flatMap((item) => item.invoiceIds), + ), + loadPackingSlipRelationMap( + this.db, + withAudit.flatMap((item) => item.packingSlipIds), + ), + ]); + return withAudit.map((item) => ({ + ...item, + employee: catalogRelationFromMap(employees, item.employeeId), + startBranch: catalogRelationFromMap(branches, item.startBranchId), + endBranch: catalogRelationFromMap(branches, item.endBranchId), + destinations: item.destinations.map((destination) => ({ + ...destination, + customer: catalogRelationFromMap(customers, destination.customerId), + })), + invoices: item.invoiceIds + .map((id) => invoices.get(id)) + .filter((value): value is NonNullable => Boolean(value)), + packingSlips: item.packingSlipIds + .map((id) => packing.get(id)) + .filter((value): value is NonNullable => Boolean(value)), + })); + } + + private async hydrateOne( + row: PlanRow, + destinations: PlanDestinationRow[], + invoices: PlanInvoiceRow[], + packingSlips: PlanPackingSlipRow[], + ): Promise { + const [item] = await this.hydrate([ + this.toDomain(row, destinations, invoices, packingSlips), + ]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { const err = this.unwrapDbError(error); if (err.code === '23505') { diff --git a/src/modules/field/plans/plans.service.spec.ts b/src/modules/field/plans/plans.service.spec.ts index a56a055..5aaaa10 100644 --- a/src/modules/field/plans/plans.service.spec.ts +++ b/src/modules/field/plans/plans.service.spec.ts @@ -68,8 +68,17 @@ describe('PlansService', () => { weekday: 'monday', startBranchId: 'br-1', endBranchId: 'br-2', + startBranch: { id: 'br-1', code: 'B1', name: 'Start' }, + endBranch: { id: 'br-2', code: 'B2', name: 'End' }, routeGeometry: geometry, - destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }], + destinations: [ + { + id: 'cd-1', + customerId: 'cus-1', + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + sortOrder: 0, + }, + ], }, ], status: Status.create('active'), @@ -77,6 +86,9 @@ describe('PlansService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const plan: Plan = { @@ -87,14 +99,28 @@ describe('PlansService', () => { startBranchId: 'br-1', endBranchId: 'br-2', routeGeometry: geometry, - destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }], + destinations: [ + { + id: 'pd-1', + customerId: 'cus-1', + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + sortOrder: 0, + }, + ], invoiceIds: [], packingSlipIds: [], + invoices: [], + packingSlips: [], + startBranch: { id: 'br-1', code: 'B1', name: 'Start' }, + endBranch: { id: 'br-2', code: 'B2', name: 'End' }, + employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, status: Status.create('active'), createdAt: now, updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; beforeEach(async () => { @@ -196,7 +222,12 @@ describe('PlansService', () => { ...plan, destinations: [ ...plan.destinations, - { id: 'pd-2', customerId: 'cus-2', sortOrder: 1 }, + { + id: 'pd-2', + customerId: 'cus-2', + customer: { id: 'cus-2', code: 'C2', name: 'Beta' }, + sortOrder: 1, + }, ], }); await service.addDestination('pln-1', 'cus-2', undefined, user); diff --git a/src/modules/field/plans/plans.service.ts b/src/modules/field/plans/plans.service.ts index b8eef55..f6f4f7b 100644 --- a/src/modules/field/plans/plans.service.ts +++ b/src/modules/field/plans/plans.service.ts @@ -7,7 +7,12 @@ import { } from '@nestjs/common'; import type { AuthUser } from '../../../common/auth/auth-user'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error'; @@ -40,6 +45,8 @@ export type ListPlansQuery = { readonly date?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -96,6 +103,8 @@ export class PlansService { : undefined, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, purposes: query.purpose ? undefined : purposes, limit: page.limit, offset: page.offset, @@ -390,24 +399,24 @@ export class PlansService { toItem(plan: Plan) { return { id: plan.id, - employeeId: plan.employeeId, + employee: pickRelation(plan.employee, DEFAULT_RELATION_FIELDS), purpose: plan.purpose, date: plan.date.value, - startBranchId: plan.startBranchId, - endBranchId: plan.endBranchId, + startBranch: pickRelation(plan.startBranch, DEFAULT_RELATION_FIELDS), + endBranch: pickRelation(plan.endBranch, DEFAULT_RELATION_FIELDS), routeGeometry: plan.routeGeometry, destinations: plan.destinations.map((destination) => ({ id: destination.id, - customerId: destination.customerId, + customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS), sortOrder: destination.sortOrder, })), - invoiceIds: [...plan.invoiceIds], - packingSlipIds: [...plan.packingSlipIds], + invoices: [...plan.invoices], + packingSlips: [...plan.packingSlips], status: plan.status.value, createdAt: plan.createdAt.value, updatedAt: plan.updatedAt.value, - createdBy: plan.createdBy, - updatedBy: plan.updatedBy, + createdBy: pickUserRelation(plan.createdByUser), + updatedBy: pickUserRelation(plan.updatedByUser), }; } diff --git a/src/modules/privileges/dto/privilege.dto.ts b/src/modules/privileges/dto/privilege.dto.ts index 9541d87..7c17a74 100644 --- a/src/modules/privileges/dto/privilege.dto.ts +++ b/src/modules/privileges/dto/privilege.dto.ts @@ -12,7 +12,10 @@ import { MaxLength, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../common/http/response'; import { CORE_STATUSES } from '../../../common/value-objects/status/status'; import { PRIVILEGE_ACTIONS } from '../privilege-action'; @@ -178,11 +181,11 @@ export class PrivilegeDto { @ApiProperty({ description: 'Unix ms' }) updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } export class PrivilegeDetailResponseDto extends PrivilegeDto { diff --git a/src/modules/privileges/privilege.ts b/src/modules/privileges/privilege.ts index e8585fb..9c17864 100644 --- a/src/modules/privileges/privilege.ts +++ b/src/modules/privileges/privilege.ts @@ -1,3 +1,4 @@ +import type { UserRelation } from '../../common/http/response'; import { DateTime } from '../../common/value-objects/date-time/date-time'; import { Status } from '../../common/value-objects/status/status'; import type { PrivilegeAction } from './privilege-action'; @@ -21,6 +22,8 @@ export type Privilege = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type PrivilegeWithDetails = Privilege & { @@ -60,12 +63,16 @@ export type ListPrivilegesFilters = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; export type ListPrivilegeKeysFilters = { readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/privileges/privileges.repository.ts b/src/modules/privileges/privileges.repository.ts index f3d550d..555acf0 100644 --- a/src/modules/privileges/privileges.repository.ts +++ b/src/modules/privileges/privileges.repository.ts @@ -5,6 +5,8 @@ import { 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 { Status } from '../../common/value-objects/status/status'; import { DRIZZLE, type DrizzleDB } from '../../database/database.module'; @@ -30,6 +32,22 @@ import type { UpdatePrivilegeInput, } from './privilege'; +const PRIVILEGE_ORDER_COLUMNS = { + id: privileges.id, + name: privileges.name, + code: privileges.code, + status: privileges.status, + createdAt: privileges.createdAt, + updatedAt: privileges.updatedAt, +}; + +const PRIVILEGE_KEY_ORDER_COLUMNS = { + id: privilegeKeys.id, + code: privilegeKeys.code, + label: privilegeKeys.label, + sortOrder: privilegeKeys.sortOrder, +}; + @Injectable() export class PrivilegesRepository { constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} @@ -47,12 +65,16 @@ export class PrivilegesRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(privileges.code)) + .orderBy( + ...toOrderClauses(PRIVILEGE_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row)), + data: await this.hydrate(rows), total: Number(totalRow?.total ?? 0), }; } @@ -75,7 +97,8 @@ export class PrivilegesRepository { return null; } const details = await this.loadDetails(id); - return { ...this.toDomain(row), details }; + const [base] = await this.hydrate([row]); + return { ...base, details }; } async findByCode(code: string): Promise { @@ -84,7 +107,7 @@ export class PrivilegesRepository { .from(privileges) .where(eq(privileges.code, code)) .limit(1); - return row ? this.toDomain(row) : null; + return row ? this.hydrateOne(row) : null; } async create(input: CreatePrivilegeInput): Promise { @@ -117,7 +140,8 @@ export class PrivilegesRepository { } const details = await this.loadDetails(row.id, tx); - return { ...this.toDomain(row), details }; + const [base] = await this.hydrate([row]); + return { ...base, details }; }); } catch (error) { this.rethrowUniqueViolation(error); @@ -190,7 +214,8 @@ export class PrivilegesRepository { } const details = await this.loadDetails(id, tx); - return { ...this.toDomain(row), details }; + const [base] = await this.hydrate([row]); + return { ...base, details }; }); } catch (error) { this.rethrowUniqueViolation(error); @@ -215,7 +240,7 @@ export class PrivilegesRepository { if (!row) { throw new NotFoundException('Privilege not found'); } - return this.toDomain(row); + return this.hydrateOne(row); } async bulkUpdateStatus( @@ -297,7 +322,12 @@ export class PrivilegesRepository { .select() .from(privilegeKeys) .where(where) - .orderBy(asc(privilegeKeys.sortOrder), asc(privilegeKeys.code)) + .orderBy( + ...toOrderClauses(PRIVILEGE_KEY_ORDER_COLUMNS, filters, [ + { column: 'sortOrder', type: 'ASC' }, + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); @@ -451,7 +481,7 @@ export class PrivilegesRepository { })); } - private toDomain(row: PrivilegeRow): Privilege { + private toDomain(row: PrivilegeRow) { return { id: row.id, name: row.name, @@ -464,6 +494,18 @@ export class PrivilegesRepository { }; } + private async hydrate(rows: PrivilegeRow[]): Promise { + return attachAuditUsers( + this.db, + rows.map((row) => this.toDomain(row)), + ); + } + + private async hydrateOne(row: PrivilegeRow): Promise { + const [item] = await this.hydrate([row]); + return item; + } + private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey { return { id: row.id, diff --git a/src/modules/privileges/privileges.service.spec.ts b/src/modules/privileges/privileges.service.spec.ts index feb5cda..d652c2f 100644 --- a/src/modules/privileges/privileges.service.spec.ts +++ b/src/modules/privileges/privileges.service.spec.ts @@ -36,6 +36,8 @@ describe('PrivilegesService', () => { createdAt: now, updatedAt: now, createdBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, updatedBy: 'user-1', details: [], }; diff --git a/src/modules/privileges/privileges.service.ts b/src/modules/privileges/privileges.service.ts index f1f1a9b..188278e 100644 --- a/src/modules/privileges/privileges.service.ts +++ b/src/modules/privileges/privileges.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../common/http/response'; -import { toListPage } from '../../common/http/response'; +import { pickUserRelation, toListPage } from '../../common/http/response'; import { Status } from '../../common/value-objects/status/status'; import type { PrivilegeAction } from './privilege-action'; import { assertPrivilegeAction } from './privilege-action'; @@ -23,6 +23,8 @@ export type ListPrivilegesQuery = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -30,6 +32,8 @@ export type ListPrivilegesQuery = { export type ListPrivilegeKeysQuery = { readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -59,6 +63,8 @@ export class PrivilegesService { code: query.code, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -241,6 +247,8 @@ export class PrivilegesService { const page = toListPage(query); return this.privilegesRepository.listKeys({ search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -313,8 +321,8 @@ export class PrivilegesService { status: privilege.status.value, createdAt: privilege.createdAt.value, updatedAt: privilege.updatedAt.value, - createdBy: privilege.createdBy, - updatedBy: privilege.updatedBy, + createdBy: pickUserRelation(privilege.createdByUser), + updatedBy: pickUserRelation(privilege.updatedByUser), }; } diff --git a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts index 4c5e005..9503a03 100644 --- a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts +++ b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts @@ -15,7 +15,12 @@ import { Min, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + CodeRelationDto, + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH, @@ -217,14 +222,12 @@ export class PackingSlipDto { id!: string; @ApiProperty() code!: string; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) - salesOrderId!: string | null; - @ApiPropertyOptional({ nullable: true }) - salesOrderNumber!: string | null; + @ApiPropertyOptional({ type: CodeRelationDto, nullable: true }) + salesOrder!: CodeRelationDto | null; @ApiProperty() date!: number; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiProperty() address!: string; @ApiPropertyOptional({ nullable: true }) @@ -239,8 +242,8 @@ export class PackingSlipDto { createdAt!: number; @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/sales/packing-slips/packing-slip.ts b/src/modules/sales/packing-slips/packing-slip.ts index eac2a8b..ca4ccee 100644 --- a/src/modules/sales/packing-slips/packing-slip.ts +++ b/src/modules/sales/packing-slips/packing-slip.ts @@ -1,3 +1,8 @@ +import type { + CodeRelation, + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status'; export type PackingSlipLine = { readonly id: string; readonly productId: string; + readonly product: DefaultRelation | null; readonly quantity: Decimal; readonly price: Decimal; }; @@ -26,6 +32,10 @@ export type PackingSlip = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly salesOrder: CodeRelation | null; + readonly customer: DefaultRelation | null; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type PackingSlipLineInput = { @@ -69,6 +79,8 @@ export type ListPackingSlipsFilters = { readonly customerId?: string; readonly salesOrderId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/sales/packing-slips/packing-slips.repository.ts b/src/modules/sales/packing-slips/packing-slips.repository.ts index cc40cb2..2c5804e 100644 --- a/src/modules/sales/packing-slips/packing-slips.repository.ts +++ b/src/modules/sales/packing-slips/packing-slips.repository.ts @@ -4,7 +4,16 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + codeRelationFromMap, + loadCustomerRelationMap, + loadProductRelationMap, + loadSalesOrderRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachAuditUsers } from '../../../database/load-user-refs'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -26,6 +35,15 @@ import type { UpdatePackingSlipInput, } from './packing-slip'; +const PACKING_SLIP_ORDER_COLUMNS = { + id: packingSlips.id, + code: packingSlips.code, + date: packingSlips.date, + status: packingSlips.status, + createdAt: packingSlips.createdAt, + updatedAt: packingSlips.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -47,11 +65,15 @@ export class PackingSlipsRepository { .select() .from(packingSlips) .where(where) - .orderBy(asc(packingSlips.code)) + .orderBy( + ...toOrderClauses(PACKING_SLIP_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -67,7 +89,7 @@ export class PackingSlipsRepository { return null; } const products = await this.selectProducts(this.db, id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); } async create(input: CreatePackingSlipInput): Promise { @@ -90,7 +112,7 @@ export class PackingSlipsRepository { const row = inserted[0]; await this.replaceProducts(tx, row.id, input.products); const products = await this.selectProducts(tx, row.id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -153,7 +175,7 @@ export class PackingSlipsRepository { await this.replaceProducts(tx, id, input.products); } const products = await this.selectProducts(tx, id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -180,7 +202,7 @@ export class PackingSlipsRepository { throw new NotFoundException('Packing slip not found'); } const products = await this.selectProducts(this.db, id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); } async bulkUpdateStatus( @@ -329,6 +351,7 @@ export class PackingSlipsRepository { products: productRows.map((line) => ({ id: line.id, productId: line.productId, + product: null, quantity: Decimal.create(line.quantity), price: Decimal.create(line.price), })), @@ -337,9 +360,52 @@ export class PackingSlipsRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + salesOrder: null, + customer: null, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: PackingSlip[]): Promise { + const withAudit = await attachAuditUsers(this.db, items); + const [customers, orders, products] = await Promise.all([ + loadCustomerRelationMap( + this.db, + withAudit.map((item) => item.customerId), + ), + loadSalesOrderRelationMap( + this.db, + withAudit + .map((item) => item.salesOrderId) + .filter((id): id is string => Boolean(id)), + ), + loadProductRelationMap( + this.db, + withAudit.flatMap((item) => + item.products.map((line) => line.productId), + ), + ), + ]); + return withAudit.map((item) => ({ + ...item, + customer: catalogRelationFromMap(customers, item.customerId), + salesOrder: codeRelationFromMap(orders, item.salesOrderId), + products: item.products.map((line) => ({ + ...line, + product: catalogRelationFromMap(products, line.productId), + })), + })); + } + + private async hydrateOne( + row: PackingSlipRow, + products: PackingSlipProductRow[], + ): Promise { + const [item] = await this.hydrate([this.toDomain(row, products)]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; diff --git a/src/modules/sales/packing-slips/packing-slips.service.spec.ts b/src/modules/sales/packing-slips/packing-slips.service.spec.ts index 85a0720..36f66ce 100644 --- a/src/modules/sales/packing-slips/packing-slips.service.spec.ts +++ b/src/modules/sales/packing-slips/packing-slips.service.spec.ts @@ -57,6 +57,7 @@ describe('PackingSlipsService', () => { { id: 'line-1', productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, quantity: Decimal.create('2'), price: Decimal.create('12500'), }, @@ -66,6 +67,10 @@ describe('PackingSlipsService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + salesOrder: null, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const createBody = { diff --git a/src/modules/sales/packing-slips/packing-slips.service.ts b/src/modules/sales/packing-slips/packing-slips.service.ts index bdc78f8..d342407 100644 --- a/src/modules/sales/packing-slips/packing-slips.service.ts +++ b/src/modules/sales/packing-slips/packing-slips.service.ts @@ -4,7 +4,13 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickCodeRelation, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; @@ -42,6 +48,8 @@ export type ListPackingSlipsQuery = { readonly customerId?: string; readonly salesOrderId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -70,6 +78,8 @@ export class PackingSlipsService { customerId: query.customerId, salesOrderId: query.salesOrderId, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -272,10 +282,9 @@ export class PackingSlipsService { return { id: item.id, code: item.code, - salesOrderId: item.salesOrderId, - salesOrderNumber: item.salesOrderNumber, + salesOrder: pickCodeRelation(item.salesOrder), date: item.date.value, - customerId: item.customerId, + customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS), address: item.address, latitude: item.latitude, longitude: item.longitude, @@ -283,8 +292,8 @@ export class PackingSlipsService { status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, - createdBy: item.createdBy, - updatedBy: item.updatedBy, + createdBy: pickUserRelation(item.createdByUser), + updatedBy: pickUserRelation(item.updatedByUser), }; } @@ -293,7 +302,7 @@ export class PackingSlipsService { ...this.toListItem(item), products: item.products.map((line) => ({ id: line.id, - productId: line.productId, + product: pickRelation(line.product, DEFAULT_RELATION_FIELDS), quantity: line.quantity.value, price: line.price.value, })), @@ -331,7 +340,7 @@ export class PackingSlipsService { salesOrderId: input.salesOrderId, salesOrderNumber: source.code, date: input.date ?? DateTime.fromUnixMs(source.date).format(), - customerId: input.customerId ?? source.customerId, + customerId: input.customerId ?? source.customer?.id ?? '', address: input.address ?? source.address, latitude: input.latitude !== undefined ? input.latitude : source.latitude, longitude: @@ -340,7 +349,7 @@ export class PackingSlipsService { products: input.products ?? source.products.map((line) => ({ - productId: line.productId, + productId: line.product?.id ?? '', quantity: line.quantity, price: line.price, })), diff --git a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts index abef278..82da22b 100644 --- a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts +++ b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts @@ -12,7 +12,12 @@ import { MaxLength, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + CodeRelationDto, + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_PATTERN, @@ -232,24 +237,20 @@ export class SalesInvoiceDto { id!: string; @ApiProperty() code!: string; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) - salesOrderId!: string | null; - @ApiPropertyOptional({ nullable: true }) - salesOrderCode!: string | null; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) - packingSlipId!: string | null; - @ApiPropertyOptional({ nullable: true }) - packingSlipCode!: string | null; + @ApiPropertyOptional({ type: CodeRelationDto, nullable: true }) + salesOrder!: CodeRelationDto | null; + @ApiPropertyOptional({ type: CodeRelationDto, nullable: true }) + packingSlip!: CodeRelationDto | null; @ApiProperty() date!: number; - @ApiProperty({ format: 'uuid' }) - salesPersonId!: string; - @ApiProperty({ format: 'uuid' }) - branchId!: string; - @ApiProperty({ format: 'uuid' }) - divisionId!: string; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + salesPerson!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + branch!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + division!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiPropertyOptional({ nullable: true }) notes!: string | null; @ApiProperty() @@ -260,8 +261,8 @@ export class SalesInvoiceDto { createdAt!: number; @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/sales/sales-invoices/sales-invoice.ts b/src/modules/sales/sales-invoices/sales-invoice.ts index d880ec2..18fb3bc 100644 --- a/src/modules/sales/sales-invoices/sales-invoice.ts +++ b/src/modules/sales/sales-invoices/sales-invoice.ts @@ -1,3 +1,8 @@ +import type { + CodeRelation, + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status'; export type SalesInvoiceLine = { readonly id: string; readonly productId: string; + readonly product: DefaultRelation | null; readonly quantity: Decimal; readonly price: Decimal; }; @@ -29,6 +35,14 @@ export type SalesInvoice = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly salesOrder: CodeRelation | null; + readonly packingSlip: CodeRelation | null; + readonly salesPerson: DefaultRelation | null; + readonly branch: DefaultRelation | null; + readonly division: DefaultRelation | null; + readonly customer: DefaultRelation | null; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type SalesInvoiceLineInput = { @@ -80,6 +94,8 @@ export type ListSalesInvoicesFilters = { readonly salesOrderId?: string; readonly packingSlipId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts index 470e390..3ed5ffd 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.repository.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -5,6 +5,15 @@ import { NotFoundException, } from '@nestjs/common'; import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + codeRelationFromMap, + loadPackingSlipRelationMap, + loadProductRelationMap, + loadSalesOrderRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachSalesHeaderRelations } from '../shared/attach-sales-relations'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -30,6 +39,15 @@ import type { UpdateSalesInvoiceInput, } from './sales-invoice'; +const SALES_INVOICE_ORDER_COLUMNS = { + id: salesInvoices.id, + code: salesInvoices.code, + date: salesInvoices.date, + status: salesInvoices.status, + createdAt: salesInvoices.createdAt, + updatedAt: salesInvoices.updatedAt, +}; + type QueryExecutor = Pick; export type InvoiceTotals = { @@ -57,11 +75,15 @@ export class SalesInvoicesRepository { .select() .from(salesInvoices) .where(where) - .orderBy(asc(salesInvoices.code)) + .orderBy( + ...toOrderClauses(SALES_INVOICE_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -77,7 +99,7 @@ export class SalesInvoicesRepository { return null; } const products = await this.selectProducts(this.db, id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); } async create(input: CreateSalesInvoiceInput): Promise { @@ -101,7 +123,7 @@ export class SalesInvoicesRepository { await this.replaceProducts(tx, row.id, input.products); const products = await this.selectProducts(tx, row.id); const withBalance = await this.refreshBalance(tx, row.id, products); - return this.toDomain(withBalance, products); + return this.hydrateOne(withBalance, products); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -169,7 +191,7 @@ export class SalesInvoicesRepository { } const products = await this.selectProducts(tx, id); const withBalance = await this.refreshBalance(tx, id, products); - return this.toDomain(withBalance, products); + return this.hydrateOne(withBalance, products); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -196,7 +218,7 @@ export class SalesInvoicesRepository { throw new NotFoundException('Sales invoice not found'); } const products = await this.selectProducts(this.db, id); - return this.toDomain(row, products); + return this.hydrateOne(row, products); } async bulkUpdateStatus( @@ -249,7 +271,7 @@ export class SalesInvoicesRepository { async refreshStoredBalance(invoiceId: string): Promise { const products = await this.selectProducts(this.db, invoiceId); const row = await this.refreshBalance(this.db, invoiceId, products); - return this.toDomain(row, products); + return this.hydrateOne(row, products); } private async totalsFrom( @@ -419,6 +441,7 @@ export class SalesInvoicesRepository { products: productRows.map((line) => ({ id: line.id, productId: line.productId, + product: null, quantity: Decimal.create(line.quantity), price: Decimal.create(line.price), })), @@ -427,9 +450,58 @@ export class SalesInvoicesRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + salesOrder: null, + packingSlip: null, + salesPerson: null, + branch: null, + division: null, + customer: null, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: SalesInvoice[]): Promise { + const withHeader = await attachSalesHeaderRelations(this.db, items); + const [orders, packing, products] = await Promise.all([ + loadSalesOrderRelationMap( + this.db, + withHeader + .map((item) => item.salesOrderId) + .filter((id): id is string => Boolean(id)), + ), + loadPackingSlipRelationMap( + this.db, + withHeader + .map((item) => item.packingSlipId) + .filter((id): id is string => Boolean(id)), + ), + loadProductRelationMap( + this.db, + withHeader.flatMap((item) => + item.products.map((line) => line.productId), + ), + ), + ]); + return withHeader.map((item) => ({ + ...item, + salesOrder: codeRelationFromMap(orders, item.salesOrderId), + packingSlip: codeRelationFromMap(packing, item.packingSlipId), + products: item.products.map((line) => ({ + ...line, + product: catalogRelationFromMap(products, line.productId), + })), + })); + } + + private async hydrateOne( + row: SalesInvoiceRow, + products: SalesInvoiceProductRow[], + ): Promise { + const [item] = await this.hydrate([this.toDomain(row, products)]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts index 3bb4b37..1e69307 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts @@ -72,6 +72,7 @@ describe('SalesInvoicesService', () => { { id: 'line-1', productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, quantity: Decimal.create('2'), price: Decimal.create('12500'), }, @@ -81,6 +82,14 @@ describe('SalesInvoicesService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + salesOrder: null, + packingSlip: null, + salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' }, + branch: { id: 'br-1', code: 'B1', name: 'Jakarta' }, + division: { id: 'div-1', code: 'FIN', name: 'Finance' }, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const createBody = { @@ -134,15 +143,15 @@ describe('SalesInvoicesService', () => { id: 'so-1', code: 'SO-1', date: now.value, - salesPersonId: 'emp-1', - branchId: 'br-1', - divisionId: 'div-1', - customerId: 'cus-1', + salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' }, + branch: { id: 'br-1', code: 'B1', name: 'Jakarta' }, + division: { id: 'div-1', code: 'FIN', name: 'Finance' }, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, notes: 'from order', products: [ { id: 'ol-1', - productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, quantity: '2.0000', price: '12500.0000', }, diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.ts b/src/modules/sales/sales-invoices/sales-invoices.service.ts index 42a130c..da759d6 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.ts @@ -4,7 +4,13 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickCodeRelation, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; @@ -47,6 +53,8 @@ export type ListSalesInvoicesQuery = { readonly salesOrderId?: string; readonly packingSlipId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -89,6 +97,8 @@ export class SalesInvoicesService { salesOrderId: query.salesOrderId, packingSlipId: query.packingSlipId, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -316,22 +326,20 @@ export class SalesInvoicesService { return { id: item.id, code: item.code, - salesOrderId: item.salesOrderId, - salesOrderCode: item.salesOrderCode, - packingSlipId: item.packingSlipId, - packingSlipCode: item.packingSlipCode, + salesOrder: pickCodeRelation(item.salesOrder), + packingSlip: pickCodeRelation(item.packingSlip), date: item.date.value, - salesPersonId: item.salesPersonId, - branchId: item.branchId, - divisionId: item.divisionId, - customerId: item.customerId, + salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS), + branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS), + division: pickRelation(item.division, DEFAULT_RELATION_FIELDS), + customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS), notes: item.notes, balance: item.balance.value, status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, - createdBy: item.createdBy, - updatedBy: item.updatedBy, + createdBy: pickUserRelation(item.createdByUser), + updatedBy: pickUserRelation(item.updatedByUser), }; } @@ -340,7 +348,7 @@ export class SalesInvoicesService { ...this.toListItem(item), products: item.products.map((line) => ({ id: line.id, - productId: line.productId, + product: pickRelation(line.product, DEFAULT_RELATION_FIELDS), quantity: line.quantity.value, price: line.price.value, })), @@ -374,15 +382,15 @@ export class SalesInvoicesService { const order = await this.salesOrdersService.findById(input.salesOrderId); salesOrderCode = order.code; date = date || DateTime.fromUnixMs(order.date).format(); - salesPersonId = salesPersonId || order.salesPersonId; - branchId = branchId || order.branchId; - divisionId = divisionId || order.divisionId; - customerId = customerId || order.customerId; + salesPersonId = salesPersonId || order.salesPerson?.id || ''; + branchId = branchId || order.branch?.id || ''; + divisionId = divisionId || order.division?.id || ''; + customerId = customerId || order.customer?.id || ''; notes = notes !== undefined ? notes : order.notes; products = products ?? order.products.map((line) => ({ - productId: line.productId, + productId: line.product?.id ?? '', quantity: line.quantity, price: line.price, })); @@ -391,13 +399,13 @@ export class SalesInvoicesService { const slip = await this.packingSlipsService.findById(input.packingSlipId); packingSlipCode = slip.code; date = date || DateTime.fromUnixMs(slip.date).format(); - customerId = customerId || slip.customerId; + customerId = customerId || slip.customer?.id || ''; notes = notes !== undefined ? notes : slip.notes; products = input.products ?? products ?? slip.products.map((line) => ({ - productId: line.productId, + productId: line.product?.id ?? '', quantity: line.quantity, price: line.price, })); diff --git a/src/modules/sales/sales-orders/dto/sales-order.dto.ts b/src/modules/sales/sales-orders/dto/sales-order.dto.ts index 1f765e1..4fbd2c8 100644 --- a/src/modules/sales/sales-orders/dto/sales-order.dto.ts +++ b/src/modules/sales/sales-orders/dto/sales-order.dto.ts @@ -15,7 +15,12 @@ import { Min, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + CodeRelationDto, + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH, @@ -272,18 +277,18 @@ export class SalesOrderDto { id!: string; @ApiProperty() code!: string; - @ApiPropertyOptional({ nullable: true, format: 'uuid' }) - salesRequestId!: string | null; + @ApiPropertyOptional({ type: CodeRelationDto, nullable: true }) + salesRequest!: CodeRelationDto | null; @ApiProperty() date!: number; - @ApiProperty({ format: 'uuid' }) - salesPersonId!: string; - @ApiProperty({ format: 'uuid' }) - branchId!: string; - @ApiProperty({ format: 'uuid' }) - divisionId!: string; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + salesPerson!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + branch!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + division!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiProperty() address!: string; @ApiPropertyOptional({ nullable: true }) @@ -298,8 +303,8 @@ export class SalesOrderDto { createdAt!: number; @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/sales/sales-orders/sales-order.ts b/src/modules/sales/sales-orders/sales-order.ts index 49ae87a..f404dc1 100644 --- a/src/modules/sales/sales-orders/sales-order.ts +++ b/src/modules/sales/sales-orders/sales-order.ts @@ -1,3 +1,8 @@ +import type { + CodeRelation, + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status'; export type SalesOrderLine = { readonly id: string; readonly productId: string; + readonly product: DefaultRelation | null; readonly quantity: Decimal; readonly price: Decimal; }; @@ -35,6 +41,13 @@ export type SalesOrder = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly salesRequest: CodeRelation | null; + readonly salesPerson: DefaultRelation | null; + readonly branch: DefaultRelation | null; + readonly division: DefaultRelation | null; + readonly customer: DefaultRelation | null; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type SalesOrderLineInput = { @@ -90,6 +103,8 @@ export type ListSalesOrdersFilters = { readonly branchId?: string; readonly divisionId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/sales/sales-orders/sales-orders.repository.ts b/src/modules/sales/sales-orders/sales-orders.repository.ts index 710e802..4760f84 100644 --- a/src/modules/sales/sales-orders/sales-orders.repository.ts +++ b/src/modules/sales/sales-orders/sales-orders.repository.ts @@ -4,7 +4,15 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + codeRelationFromMap, + loadProductRelationMap, + loadSalesRequestRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachSalesHeaderRelations } from '../shared/attach-sales-relations'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -29,6 +37,15 @@ import type { UpdateSalesOrderInput, } from './sales-order'; +const SALES_ORDER_ORDER_COLUMNS = { + id: salesOrders.id, + code: salesOrders.code, + date: salesOrders.date, + status: salesOrders.status, + createdAt: salesOrders.createdAt, + updatedAt: salesOrders.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -50,11 +67,15 @@ export class SalesOrdersRepository { .select() .from(salesOrders) .where(where) - .orderBy(asc(salesOrders.code)) + .orderBy( + ...toOrderClauses(SALES_ORDER_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [], [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -71,7 +92,7 @@ export class SalesOrdersRepository { } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); } async create(input: CreateSalesOrderInput): Promise { @@ -95,7 +116,7 @@ export class SalesOrdersRepository { await this.replaceImages(tx, row.id, input.images ?? []); const products = await this.selectProducts(tx, row.id); const images = await this.selectImages(tx, row.id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -154,7 +175,7 @@ export class SalesOrdersRepository { } const products = await this.selectProducts(tx, id); const images = await this.selectImages(tx, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -182,7 +203,7 @@ export class SalesOrdersRepository { } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); } async bulkUpdateStatus( @@ -372,6 +393,7 @@ export class SalesOrdersRepository { products: productRows.map((line) => ({ id: line.id, productId: line.productId, + product: null, quantity: Decimal.create(line.quantity), price: Decimal.create(line.price), })), @@ -385,9 +407,47 @@ export class SalesOrdersRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + salesRequest: null, + salesPerson: null, + branch: null, + division: null, + customer: null, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: SalesOrder[]): Promise { + const withHeader = await attachSalesHeaderRelations(this.db, items); + const productIds = withHeader.flatMap((item) => + item.products.map((line) => line.productId), + ); + const requestIds = withHeader + .map((item) => item.salesRequestId) + .filter((id): id is string => Boolean(id)); + const [products, requests] = await Promise.all([ + loadProductRelationMap(this.db, productIds), + loadSalesRequestRelationMap(this.db, requestIds), + ]); + return withHeader.map((item) => ({ + ...item, + salesRequest: codeRelationFromMap(requests, item.salesRequestId), + products: item.products.map((line) => ({ + ...line, + product: catalogRelationFromMap(products, line.productId), + })), + })); + } + + private async hydrateOne( + row: SalesOrderRow, + products: SalesOrderProductRow[], + images: SalesOrderImageRow[], + ): Promise { + const [item] = await this.hydrate([this.toDomain(row, products, images)]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; diff --git a/src/modules/sales/sales-orders/sales-orders.service.spec.ts b/src/modules/sales/sales-orders/sales-orders.service.spec.ts index f8ea58c..59e0cd1 100644 --- a/src/modules/sales/sales-orders/sales-orders.service.spec.ts +++ b/src/modules/sales/sales-orders/sales-orders.service.spec.ts @@ -65,6 +65,7 @@ describe('SalesOrdersService', () => { { id: 'line-1', productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, quantity: Decimal.create('2'), price: Decimal.create('12500'), }, @@ -75,6 +76,13 @@ describe('SalesOrdersService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + salesRequest: null, + salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' }, + branch: { id: 'br-1', code: 'B1', name: 'Jakarta' }, + division: { id: 'div-1', code: 'FIN', name: 'Finance' }, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const createBody = { diff --git a/src/modules/sales/sales-orders/sales-orders.service.ts b/src/modules/sales/sales-orders/sales-orders.service.ts index 98fb595..b8f3b4f 100644 --- a/src/modules/sales/sales-orders/sales-orders.service.ts +++ b/src/modules/sales/sales-orders/sales-orders.service.ts @@ -4,7 +4,13 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickCodeRelation, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; @@ -55,6 +61,8 @@ export type ListSalesOrdersQuery = { readonly branchId?: string; readonly divisionId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -93,6 +101,8 @@ export class SalesOrdersService { branchId: query.branchId, divisionId: query.divisionId, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -303,12 +313,12 @@ export class SalesOrdersService { return { id: item.id, code: item.code, - salesRequestId: item.salesRequestId, + salesRequest: pickCodeRelation(item.salesRequest), date: item.date.value, - salesPersonId: item.salesPersonId, - branchId: item.branchId, - divisionId: item.divisionId, - customerId: item.customerId, + salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS), + branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS), + division: pickRelation(item.division, DEFAULT_RELATION_FIELDS), + customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS), address: item.address, latitude: item.latitude, longitude: item.longitude, @@ -316,8 +326,8 @@ export class SalesOrdersService { status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, - createdBy: item.createdBy, - updatedBy: item.updatedBy, + createdBy: pickUserRelation(item.createdByUser), + updatedBy: pickUserRelation(item.updatedByUser), }; } @@ -326,7 +336,7 @@ export class SalesOrdersService { ...this.toListItem(item), products: item.products.map((line) => ({ id: line.id, - productId: line.productId, + product: pickRelation(line.product, DEFAULT_RELATION_FIELDS), quantity: line.quantity.value, price: line.price.value, })), @@ -373,10 +383,10 @@ export class SalesOrdersService { return { ...input, date: input.date ?? DateTime.fromUnixMs(source.date).format(), - salesPersonId: input.salesPersonId ?? source.salesPersonId, - branchId: input.branchId ?? source.branchId, - divisionId: input.divisionId ?? source.divisionId, - customerId: input.customerId ?? source.customerId, + salesPersonId: input.salesPersonId ?? source.salesPerson?.id ?? '', + branchId: input.branchId ?? source.branch?.id ?? '', + divisionId: input.divisionId ?? source.division?.id ?? '', + customerId: input.customerId ?? source.customer?.id ?? '', address: input.address ?? source.address, latitude: input.latitude !== undefined ? input.latitude : source.latitude, longitude: @@ -385,7 +395,7 @@ export class SalesOrdersService { products: input.products ?? source.products.map((line) => ({ - productId: line.productId, + productId: line.product?.id ?? '', quantity: line.quantity, price: line.price, })), diff --git a/src/modules/sales/sales-payments/dto/sales-payment.dto.ts b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts index eecc3e6..7cff91c 100644 --- a/src/modules/sales/sales-payments/dto/sales-payment.dto.ts +++ b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts @@ -12,7 +12,10 @@ import { MaxLength, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_PATTERN, @@ -177,8 +180,8 @@ export class SalesPaymentDto { createdAt!: number; @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/sales/sales-payments/sales-payment.ts b/src/modules/sales/sales-payments/sales-payment.ts index 374f2f4..653b06a 100644 --- a/src/modules/sales/sales-payments/sales-payment.ts +++ b/src/modules/sales/sales-payments/sales-payment.ts @@ -1,3 +1,4 @@ +import type { CodeRelation, UserRelation } from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -5,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status'; export type SalesPaymentAllocation = { readonly id: string; readonly invoiceId: string; + readonly invoice: CodeRelation | null; readonly amount: Decimal; }; @@ -26,6 +28,8 @@ export type SalesPayment = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type SalesPaymentAllocationInput = { @@ -61,6 +65,8 @@ export type ListSalesPaymentsFilters = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/sales/sales-payments/sales-payments.repository.ts b/src/modules/sales/sales-payments/sales-payments.repository.ts index cd45d2e..92a6d5b 100644 --- a/src/modules/sales/sales-payments/sales-payments.repository.ts +++ b/src/modules/sales/sales-payments/sales-payments.repository.ts @@ -4,7 +4,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + codeRelationFromMap, + loadSalesInvoiceRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachAuditUsers } from '../../../database/load-user-refs'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -29,6 +35,15 @@ import type { UpdateSalesPaymentInput, } from './sales-payment'; +const SALES_PAYMENT_ORDER_COLUMNS = { + id: salesPayments.id, + code: salesPayments.code, + date: salesPayments.date, + status: salesPayments.status, + createdAt: salesPayments.createdAt, + updatedAt: salesPayments.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -50,11 +65,15 @@ export class SalesPaymentsRepository { .select() .from(salesPayments) .where(where) - .orderBy(asc(salesPayments.code)) + .orderBy( + ...toOrderClauses(SALES_PAYMENT_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [], [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -71,7 +90,7 @@ export class SalesPaymentsRepository { } const invoices = await this.selectAllocations(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, invoices, images); + return this.hydrateOne(row, invoices, images); } async create(input: CreateSalesPaymentInput): Promise { @@ -96,7 +115,7 @@ export class SalesPaymentsRepository { await this.replaceImages(tx, row.id, input.images ?? []); const invoices = await this.selectAllocations(tx, row.id); const images = await this.selectImages(tx, row.id); - return this.toDomain(row, invoices, images); + return this.hydrateOne(row, invoices, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -147,7 +166,7 @@ export class SalesPaymentsRepository { } const invoices = await this.selectAllocations(tx, id); const images = await this.selectImages(tx, id); - return this.toDomain(row, invoices, images); + return this.hydrateOne(row, invoices, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -175,7 +194,7 @@ export class SalesPaymentsRepository { } const invoices = await this.selectAllocations(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, invoices, images); + return this.hydrateOne(row, invoices, images); } async bulkUpdateStatus( @@ -335,6 +354,7 @@ export class SalesPaymentsRepository { invoices: allocationRows.map((line) => ({ id: line.id, invoiceId: line.salesInvoiceId, + invoice: null, amount: Decimal.create(line.amount), })), images: imageRows.map((image) => ({ @@ -347,9 +367,35 @@ export class SalesPaymentsRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: SalesPayment[]): Promise { + const withAudit = await attachAuditUsers(this.db, items); + const invoices = await loadSalesInvoiceRelationMap( + this.db, + withAudit.flatMap((item) => item.invoices.map((line) => line.invoiceId)), + ); + return withAudit.map((item) => ({ + ...item, + invoices: item.invoices.map((line) => ({ + ...line, + invoice: codeRelationFromMap(invoices, line.invoiceId), + })), + })); + } + + private async hydrateOne( + row: SalesPaymentRow, + invoices: SalesPaymentInvoiceRow[], + images: SalesPaymentImageRow[], + ): Promise { + const [item] = await this.hydrate([this.toDomain(row, invoices, images)]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; diff --git a/src/modules/sales/sales-payments/sales-payments.service.spec.ts b/src/modules/sales/sales-payments/sales-payments.service.spec.ts index 9c2d74b..9708375 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.spec.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.spec.ts @@ -51,6 +51,7 @@ describe('SalesPaymentsService', () => { { id: 'alloc-1', invoiceId: 'si-1', + invoice: { id: 'si-1', code: 'SI-1' }, amount: Decimal.create('10000'), }, ], @@ -60,6 +61,8 @@ describe('SalesPaymentsService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; beforeEach(async () => { diff --git a/src/modules/sales/sales-payments/sales-payments.service.ts b/src/modules/sales/sales-payments/sales-payments.service.ts index 288f952..17f69e7 100644 --- a/src/modules/sales/sales-payments/sales-payments.service.ts +++ b/src/modules/sales/sales-payments/sales-payments.service.ts @@ -4,7 +4,11 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + pickCodeRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; @@ -42,6 +46,8 @@ export type ListSalesPaymentsQuery = { readonly code?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -66,6 +72,8 @@ export class SalesPaymentsService { code: query.code, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -253,8 +261,8 @@ export class SalesPaymentsService { status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, - createdBy: item.createdBy, - updatedBy: item.updatedBy, + createdBy: pickUserRelation(item.createdByUser), + updatedBy: pickUserRelation(item.updatedByUser), }; } @@ -263,7 +271,7 @@ export class SalesPaymentsService { ...this.toListItem(item), invoices: item.invoices.map((line) => ({ id: line.id, - invoiceId: line.invoiceId, + invoice: pickCodeRelation(line.invoice), amount: line.amount.value, })), images: item.images.map((image) => ({ diff --git a/src/modules/sales/sales-requests/dto/sales-request.dto.ts b/src/modules/sales/sales-requests/dto/sales-request.dto.ts index 6f90f80..c5df1fe 100644 --- a/src/modules/sales/sales-requests/dto/sales-request.dto.ts +++ b/src/modules/sales/sales-requests/dto/sales-request.dto.ts @@ -15,7 +15,11 @@ import { Min, ValidateNested, } from 'class-validator'; -import { PaginationQueryDto } from '../../../../common/http/response'; +import { + DefaultRelationDto, + PaginationQueryDto, + UserRelationDto, +} from '../../../../common/http/response'; import { DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH, @@ -269,14 +273,14 @@ export class SalesRequestDto { code!: string; @ApiProperty() date!: number; - @ApiProperty({ format: 'uuid' }) - salesPersonId!: string; - @ApiProperty({ format: 'uuid' }) - branchId!: string; - @ApiProperty({ format: 'uuid' }) - divisionId!: string; - @ApiProperty({ format: 'uuid' }) - customerId!: string; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + salesPerson!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + branch!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + division!: DefaultRelationDto | null; + @ApiProperty({ type: DefaultRelationDto, nullable: true }) + customer!: DefaultRelationDto | null; @ApiProperty() address!: string; @ApiPropertyOptional({ nullable: true }) @@ -291,8 +295,8 @@ export class SalesRequestDto { createdAt!: number; @ApiProperty() updatedAt!: number; - @ApiProperty({ format: 'uuid' }) - createdBy!: string; - @ApiProperty({ format: 'uuid' }) - updatedBy!: string; + @ApiProperty({ type: UserRelationDto }) + createdBy!: UserRelationDto; + @ApiProperty({ type: UserRelationDto }) + updatedBy!: UserRelationDto; } diff --git a/src/modules/sales/sales-requests/sales-request.ts b/src/modules/sales/sales-requests/sales-request.ts index ddf494b..9aa4000 100644 --- a/src/modules/sales/sales-requests/sales-request.ts +++ b/src/modules/sales/sales-requests/sales-request.ts @@ -1,3 +1,7 @@ +import type { + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -5,6 +9,7 @@ import { Status } from '../../../common/value-objects/status/status'; export type SalesRequestLine = { readonly id: string; readonly productId: string; + readonly product: DefaultRelation | null; readonly quantity: Decimal; readonly price: Decimal; }; @@ -34,6 +39,12 @@ export type SalesRequest = { readonly updatedAt: DateTime; readonly createdBy: string; readonly updatedBy: string; + readonly salesPerson: DefaultRelation | null; + readonly branch: DefaultRelation | null; + readonly division: DefaultRelation | null; + readonly customer: DefaultRelation | null; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; }; export type SalesRequestLineInput = { @@ -88,6 +99,8 @@ export type ListSalesRequestsFilters = { readonly branchId?: string; readonly divisionId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/sales/sales-requests/sales-requests.repository.ts b/src/modules/sales/sales-requests/sales-requests.repository.ts index 6a55e75..1554de2 100644 --- a/src/modules/sales/sales-requests/sales-requests.repository.ts +++ b/src/modules/sales/sales-requests/sales-requests.repository.ts @@ -4,7 +4,13 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../../common/http/response'; +import { + catalogRelationFromMap, + loadProductRelationMap, +} from '../../../database/load-catalog-refs'; +import { attachSalesHeaderRelations } from '../shared/attach-sales-relations'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Status } from '../../../common/value-objects/status/status'; @@ -29,6 +35,15 @@ import type { UpdateSalesRequestInput, } from './sales-request'; +const SALES_REQUEST_ORDER_COLUMNS = { + id: salesRequests.id, + code: salesRequests.code, + date: salesRequests.date, + status: salesRequests.status, + createdAt: salesRequests.createdAt, + updatedAt: salesRequests.updatedAt, +}; + type QueryExecutor = Pick; @Injectable() @@ -50,11 +65,15 @@ export class SalesRequestsRepository { .select() .from(salesRequests) .where(where) - .orderBy(asc(salesRequests.code)) + .orderBy( + ...toOrderClauses(SALES_REQUEST_ORDER_COLUMNS, filters, [ + { column: 'code', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); return { - data: rows.map((row) => this.toDomain(row, [], [])), + data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))), total: Number(totalRows[0]?.total ?? 0), }; } @@ -71,7 +90,7 @@ export class SalesRequestsRepository { } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); } async create(input: CreateSalesRequestInput): Promise { @@ -96,7 +115,7 @@ export class SalesRequestsRepository { await this.replaceImages(tx, row.id, input.images ?? []); const products = await this.selectProducts(tx, row.id); const images = await this.selectImages(tx, row.id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -158,7 +177,7 @@ export class SalesRequestsRepository { } const products = await this.selectProducts(tx, id); const images = await this.selectImages(tx, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); }); } catch (error) { this.rethrowConstraintViolation(error); @@ -186,7 +205,7 @@ export class SalesRequestsRepository { } const products = await this.selectProducts(this.db, id); const images = await this.selectImages(this.db, id); - return this.toDomain(row, products, images); + return this.hydrateOne(row, products, images); } async bulkUpdateStatus( @@ -374,6 +393,7 @@ export class SalesRequestsRepository { products: productRows.map((line) => ({ id: line.id, productId: line.productId, + product: null, quantity: Decimal.create(line.quantity), price: Decimal.create(line.price), })), @@ -387,9 +407,39 @@ export class SalesRequestsRepository { updatedAt: DateTime.fromUnixMs(row.updatedAt), createdBy: row.createdBy, updatedBy: row.updatedBy, + salesPerson: null, + branch: null, + division: null, + customer: null, + createdByUser: { id: row.createdBy, username: '' }, + updatedByUser: { id: row.updatedBy, username: '' }, }; } + private async hydrate(items: SalesRequest[]): Promise { + const withHeader = await attachSalesHeaderRelations(this.db, items); + const productIds = withHeader.flatMap((item) => + item.products.map((line) => line.productId), + ); + const products = await loadProductRelationMap(this.db, productIds); + return withHeader.map((item) => ({ + ...item, + products: item.products.map((line) => ({ + ...line, + product: catalogRelationFromMap(products, line.productId), + })), + })); + } + + private async hydrateOne( + row: SalesRequestRow, + products: SalesRequestProductRow[], + images: SalesRequestImageRow[], + ): Promise { + const [item] = await this.hydrate([this.toDomain(row, products, images)]); + return item; + } + private rethrowConstraintViolation(error: unknown): never { if (error instanceof NotFoundException) { throw error; diff --git a/src/modules/sales/sales-requests/sales-requests.service.spec.ts b/src/modules/sales/sales-requests/sales-requests.service.spec.ts index 8b5d1a8..eaed9ef 100644 --- a/src/modules/sales/sales-requests/sales-requests.service.spec.ts +++ b/src/modules/sales/sales-requests/sales-requests.service.spec.ts @@ -62,6 +62,7 @@ describe('SalesRequestsService', () => { { id: 'line-1', productId: 'prd-1', + product: { id: 'prd-1', code: 'P1', name: 'Fuel' }, quantity: Decimal.create('2'), price: Decimal.create('12500'), }, @@ -72,6 +73,12 @@ describe('SalesRequestsService', () => { updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', + salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' }, + branch: { id: 'br-1', code: 'B1', name: 'Jakarta' }, + division: { id: 'div-1', code: 'FIN', name: 'Finance' }, + customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, + createdByUser: { id: 'user-1', username: 'admin' }, + updatedByUser: { id: 'user-1', username: 'admin' }, }; const createBody = { diff --git a/src/modules/sales/sales-requests/sales-requests.service.ts b/src/modules/sales/sales-requests/sales-requests.service.ts index 4b37a8c..d9b77a4 100644 --- a/src/modules/sales/sales-requests/sales-requests.service.ts +++ b/src/modules/sales/sales-requests/sales-requests.service.ts @@ -4,7 +4,12 @@ import { NotFoundException, } from '@nestjs/common'; import type { PaginationResponse } from '../../../common/http/response'; -import { toListPage } from '../../../common/http/response'; +import { + DEFAULT_RELATION_FIELDS, + pickRelation, + pickUserRelation, + toListPage, +} from '../../../common/http/response'; import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Decimal } from '../../../common/value-objects/decimal/decimal'; @@ -54,6 +59,8 @@ export type ListSalesRequestsQuery = { readonly branchId?: string; readonly divisionId?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -93,6 +100,8 @@ export class SalesRequestsService { branchId: query.branchId, divisionId: query.divisionId, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); @@ -302,10 +311,10 @@ export class SalesRequestsService { id: item.id, code: item.code, date: item.date.value, - salesPersonId: item.salesPersonId, - branchId: item.branchId, - divisionId: item.divisionId, - customerId: item.customerId, + salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS), + branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS), + division: pickRelation(item.division, DEFAULT_RELATION_FIELDS), + customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS), address: item.address, latitude: item.latitude, longitude: item.longitude, @@ -313,8 +322,8 @@ export class SalesRequestsService { status: item.status.value, createdAt: item.createdAt.value, updatedAt: item.updatedAt.value, - createdBy: item.createdBy, - updatedBy: item.updatedBy, + createdBy: pickUserRelation(item.createdByUser), + updatedBy: pickUserRelation(item.updatedByUser), }; } @@ -323,7 +332,7 @@ export class SalesRequestsService { ...this.toListItem(item), products: item.products.map((line) => ({ id: line.id, - productId: line.productId, + product: pickRelation(line.product, DEFAULT_RELATION_FIELDS), quantity: line.quantity.value, price: line.price.value, })), diff --git a/src/modules/sales/shared/attach-sales-relations.ts b/src/modules/sales/shared/attach-sales-relations.ts new file mode 100644 index 0000000..6bbe944 --- /dev/null +++ b/src/modules/sales/shared/attach-sales-relations.ts @@ -0,0 +1,82 @@ +import type { + DefaultRelation, + UserRelation, +} from '../../../common/http/response'; +import type { DrizzleDB } from '../../../database/database.module'; +import { + catalogRelationFromMap, + loadBranchRelationMap, + loadCustomerRelationMap, + loadDivisionRelationMap, + loadEmployeeRelationMap, + loadPackingSlipRelationMap, + loadProductRelationMap, + loadSalesInvoiceRelationMap, + loadSalesOrderRelationMap, + loadSalesRequestRelationMap, +} from '../../../database/load-catalog-refs'; +import { + loadUserRelationMap, + userRelationFromMap, +} from '../../../database/load-user-refs'; + +export type SalesHeaderRelations = { + readonly salesPerson: DefaultRelation | null; + readonly branch: DefaultRelation | null; + readonly division: DefaultRelation | null; + readonly customer: DefaultRelation | null; + readonly createdByUser: UserRelation; + readonly updatedByUser: UserRelation; +}; + +export async function attachSalesHeaderRelations< + T extends { + salesPersonId: string; + branchId: string; + divisionId: string; + customerId: string; + createdBy: string; + updatedBy: string; + }, +>(db: DrizzleDB, items: T[]): Promise> { + const [employees, branches, divisions, customers, users] = await Promise.all([ + loadEmployeeRelationMap( + db, + items.map((item) => item.salesPersonId), + ), + loadBranchRelationMap( + db, + items.map((item) => item.branchId), + ), + loadDivisionRelationMap( + db, + items.map((item) => item.divisionId), + ), + loadCustomerRelationMap( + db, + items.map((item) => item.customerId), + ), + loadUserRelationMap( + db, + items.flatMap((item) => [item.createdBy, item.updatedBy]), + ), + ]); + return items.map((item) => ({ + ...item, + salesPerson: catalogRelationFromMap(employees, item.salesPersonId), + branch: catalogRelationFromMap(branches, item.branchId), + division: catalogRelationFromMap(divisions, item.divisionId), + customer: catalogRelationFromMap(customers, item.customerId), + createdByUser: userRelationFromMap(users, item.createdBy), + updatedByUser: userRelationFromMap(users, item.updatedBy), + })); +} + +export { + catalogRelationFromMap, + loadPackingSlipRelationMap, + loadProductRelationMap, + loadSalesInvoiceRelationMap, + loadSalesOrderRelationMap, + loadSalesRequestRelationMap, +}; diff --git a/src/modules/users/user.ts b/src/modules/users/user.ts index 6e939ef..78998cb 100644 --- a/src/modules/users/user.ts +++ b/src/modules/users/user.ts @@ -53,6 +53,8 @@ export type ListUsersFilters = { readonly privilegeId?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly limit: number; readonly offset: number; }; diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts index 11eb900..223d24c 100644 --- a/src/modules/users/users.repository.ts +++ b/src/modules/users/users.repository.ts @@ -4,7 +4,8 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; +import { toOrderClauses } from '../../common/http/response'; import { alias } from 'drizzle-orm/pg-core'; import { randomUUID } from 'node:crypto'; import { DateTime } from '../../common/value-objects/date-time/date-time'; @@ -23,6 +24,14 @@ import type { User, } from './user'; +const USER_ORDER_COLUMNS = { + id: users.id, + username: users.username, + status: users.status, + createdAt: users.createdAt, + updatedAt: users.updatedAt, +}; + const createdByUsers = alias(users, 'created_by_users'); const updatedByUsers = alias(users, 'updated_by_users'); @@ -57,7 +66,11 @@ export class UsersRepository { qb = this.extendListQuery(qb, filters); const rows = await qb .where(where) - .orderBy(asc(users.username)) + .orderBy( + ...toOrderClauses(USER_ORDER_COLUMNS, filters, [ + { column: 'username', type: 'ASC' }, + ]), + ) .limit(filters.limit) .offset(filters.offset); diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index ab2f501..bf7a9e6 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -69,6 +69,8 @@ describe('UsersService', () => { updatedAt: now, createdBy: 'actor-1', updatedBy: 'actor-1', + createdByUser: { id: 'actor-1', username: 'actor' }, + updatedByUser: { id: 'actor-1', username: 'actor' }, userId: null, user: null, }; diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 4f4baaf..48640e0 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -31,6 +31,8 @@ export type ListUsersQuery = { readonly privilegeId?: string; readonly status?: string; readonly search?: string; + readonly orderBy?: string; + readonly orderType?: string; readonly page?: number; readonly limit?: number; readonly offset?: number; @@ -69,6 +71,8 @@ export class UsersService { privilegeId: query.privilegeId, status: query.status, search: query.search, + orderBy: query.orderBy, + orderType: query.orderType, limit: page.limit, offset: page.offset, }); diff --git a/test/helpers/activate-user.ts b/test/helpers/activate-user.ts index 9cbbb36..5ef92d2 100644 --- a/test/helpers/activate-user.ts +++ b/test/helpers/activate-user.ts @@ -19,10 +19,7 @@ export async function registerAndActivate( ); } const userId = (register.body as { id: string }).id; - await db - .update(users) - .set({ status: 'active' }) - .where(eq(users.id, userId)); + await db.update(users).set({ status: 'active' }).where(eq(users.id, userId)); const login = await request(app.getHttpServer()) .post('/auth/login') .send({ username, password });