Enhance pagination and ordering capabilities in API responses

- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results.
- Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns.
- Enhanced `PaginationQueryDto` to support ordering fields in API requests.
- Updated various repository and service classes to implement ordering in database queries.
- Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior.
- Refactored related DTOs to include user and code relations for better data representation in responses.
This commit is contained in:
shancheas
2026-08-27 13:09:41 +07:00
parent 790725e227
commit 4c45a4371e
85 changed files with 1824 additions and 365 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ interface PaginationMeta {
- Mark every list endpoint with `@Pagination()` - Mark every list endpoint with `@Pagination()`
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller - 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 - Use `@RawResponse()` for file downloads / health probes that must skip wrapping
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped** - Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
+1 -1
View File
@@ -35,7 +35,7 @@ Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before**
List requirements: List requirements:
- Query filters for the resources own attributes **plus** `search` (case-insensitive match on the modules searchable text columns; AND with other filters) - Query filters for the resources own attributes **plus** `search` (case-insensitive match on the modules 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`) - 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 - 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` - FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc`
+16 -1
View File
@@ -23,12 +23,27 @@ export {
PAGINATION_MAX_LIMIT, PAGINATION_MAX_LIMIT,
} from './pagination.constants'; } from './pagination.constants';
export { export {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS, DEFAULT_RELATION_FIELDS,
fallbackUserRelation,
pickCodeRelation,
pickDefaultRelation, pickDefaultRelation,
pickRelation, pickRelation,
pickUserRelation, pickUserRelation,
USER_RELATION_FIELDS, USER_RELATION_FIELDS,
type CodeRelation,
type DefaultRelation, type DefaultRelation,
type UserRelation, type UserRelation,
} from './relation-fields'; } 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';
@@ -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);
});
});
+57
View File
@@ -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<string, Parameters<typeof asc>[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<string, Parameters<typeof asc>[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');
}
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, Max, Min } from 'class-validator'; 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'; import { PAGINATION_MAX_LIMIT } from './pagination.constants';
export class PaginationQueryDto { export class PaginationQueryDto {
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
@IsInt() @IsInt()
@Min(0) @Min(0)
offset?: number; 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];
} }
@@ -1,5 +1,7 @@
import { import {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS, DEFAULT_RELATION_FIELDS,
pickCodeRelation,
pickDefaultRelation, pickDefaultRelation,
pickRelation, pickRelation,
pickUserRelation, 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', () => { describe('pickDefaultRelation / pickUserRelation', () => {
it('maps catalog and user sources without leaking extra fields', () => { it('maps catalog and user sources without leaking extra fields', () => {
expect( expect(
@@ -1,5 +1,6 @@
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const; export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
export const USER_RELATION_FIELDS = ['id', 'username'] as const; export const USER_RELATION_FIELDS = ['id', 'username'] as const;
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
export type DefaultRelation = { export type DefaultRelation = {
readonly id: string; readonly id: string;
@@ -12,6 +13,11 @@ export type UserRelation = {
readonly username: string; readonly username: string;
}; };
export type CodeRelation = {
readonly id: string;
readonly code: string;
};
export function pickRelation<T, K extends keyof NonNullable<T>>( export function pickRelation<T, K extends keyof NonNullable<T>>(
source: T | null | undefined, source: T | null | undefined,
fields: readonly K[], 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: '' });
}
+8
View File
@@ -18,3 +18,11 @@ export class UserRelationDto {
@ApiProperty() @ApiProperty()
username!: string; username!: string;
} }
export class CodeRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
}
+3 -1
View File
@@ -14,7 +14,9 @@ export const employees = pgTable(
name: varchar('name', { length: 64 }).notNull(), name: varchar('name', { length: 64 }).notNull(),
phone: text('phone').notNull(), phone: text('phone').notNull(),
position: text('position').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), ...primaryEntityColumns(users),
}, },
(t) => [ (t) => [
+129
View File
@@ -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<Map<string, DefaultRelation>> {
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<Map<string, CodeRelation>> {
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<string, DefaultRelation>,
id: string | null | undefined,
): DefaultRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
export function codeRelationFromMap(
map: Map<string, CodeRelation>,
id: string | null | undefined,
): CodeRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
+47
View File
@@ -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<Map<string, UserRelation>> {
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<string, UserRelation>,
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<T & { createdByUser: UserRelation; updatedByUser: UserRelation }>
> {
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),
}));
}
@@ -82,6 +82,8 @@ export type ListBranchesFilters = {
readonly workingHoursStart?: string; readonly workingHoursStart?: string;
readonly workingHoursEnd?: string; readonly workingHoursEnd?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -5,7 +5,8 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { alias } from 'drizzle-orm/pg-core';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
@@ -24,6 +25,18 @@ import type {
UpdateBranchInput, UpdateBranchInput,
} from './branch'; } 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 createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users'); const updatedByUsers = alias(users, 'updated_by_users');
@@ -52,7 +65,11 @@ export class BranchesRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(branches.code)) .orderBy(
...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
@@ -4,7 +4,12 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -36,6 +41,8 @@ export type ListBranchesQuery = {
readonly workingHoursStart?: string; readonly workingHoursStart?: string;
readonly workingHoursEnd?: string; readonly workingHoursEnd?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -94,6 +101,8 @@ export class BranchesService {
workingHoursStart: query.workingHoursStart, workingHoursStart: query.workingHoursStart,
workingHoursEnd: query.workingHoursEnd, workingHoursEnd: query.workingHoursEnd,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -341,31 +350,15 @@ export class BranchesService {
workingHoursStart: branch.workingHoursStart, workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd, workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId, nfcId: branch.nfcId,
division: this.toDivisionItem(branch.division), division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
status: branch.status.value, status: branch.status.value,
createdAt: branch.createdAt.value, createdAt: branch.createdAt.value,
updatedAt: branch.updatedAt.value, updatedAt: branch.updatedAt.value,
createdBy: this.toUserItem(branch.createdByUser), createdBy: pickUserRelation(branch.createdByUser),
updatedBy: this.toUserItem(branch.updatedByUser), 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[] { get visibleFields(): readonly string[] {
return VISIBLE_FIELDS; return VISIBLE_FIELDS;
} }
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -27,6 +28,8 @@ export type Customer = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type CustomerContactInput = { export type CustomerContactInput = {
@@ -79,6 +82,8 @@ export type ListCustomersFilters = {
readonly nfcId?: string; readonly nfcId?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -67,7 +67,13 @@ describe('CustomersRepository', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning })); where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit })); orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset })); limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]); offset.mockResolvedValue([row]);
@@ -5,6 +5,8 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -26,6 +28,18 @@ import type {
UpdateCustomerInput, UpdateCustomerInput,
} from './customer'; } from './customer';
const CUSTOMER_ORDER_COLUMNS = {
id: customers.id,
code: customers.code,
name: customers.name,
phone: customers.phone,
address: customers.address,
nfcId: customers.nfcId,
status: customers.status,
createdAt: customers.createdAt,
updatedAt: customers.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable() @Injectable()
@@ -46,12 +60,16 @@ export class CustomersRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(customers.code)) .orderBy(
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [])), data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
total: Number(totalRow?.total ?? 0), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -75,7 +93,7 @@ export class CustomersRepository {
return null; return null;
} }
const contacts = await this.selectContacts(this.db, id); const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts); return this.hydrate(row, contacts);
} }
async findByCode(code: string): Promise<Customer | null> { async findByCode(code: string): Promise<Customer | null> {
@@ -89,7 +107,7 @@ export class CustomersRepository {
return null; return null;
} }
const contacts = await this.selectContacts(this.db, row.id); const contacts = await this.selectContacts(this.db, row.id);
return this.toDomain(row, contacts); return this.hydrate(row, contacts);
} }
async create(input: CreateCustomerInput): Promise<Customer> { async create(input: CreateCustomerInput): Promise<Customer> {
@@ -104,7 +122,7 @@ export class CustomersRepository {
const row = inserted[0]; const row = inserted[0];
await this.replaceContacts(tx, row.id, input.contacts ?? []); await this.replaceContacts(tx, row.id, input.contacts ?? []);
const contacts = await this.selectContacts(tx, row.id); const contacts = await this.selectContacts(tx, row.id);
return this.toDomain(row, contacts); return this.hydrate(row, contacts);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -170,7 +188,7 @@ export class CustomersRepository {
await this.replaceContacts(tx, id, input.contacts); await this.replaceContacts(tx, id, input.contacts);
} }
const contacts = await this.selectContacts(tx, id); const contacts = await this.selectContacts(tx, id);
return this.toDomain(row, contacts); return this.hydrate(row, contacts);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -197,7 +215,7 @@ export class CustomersRepository {
throw new NotFoundException('Customer not found'); throw new NotFoundException('Customer not found');
} }
const contacts = await this.selectContacts(this.db, id); const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts); return this.hydrate(row, contacts);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -437,10 +455,17 @@ export class CustomersRepository {
}; };
} }
private toDomain( private async hydrate(
row: CustomerRow, row: CustomerRow,
contactRows: CustomerContactRow[], contactRows: CustomerContactRow[],
): Customer { ): Promise<Customer> {
const [item] = await attachAuditUsers(this.db, [
this.toDomain(row, contactRows),
]);
return item;
}
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -52,6 +52,8 @@ describe('CustomersService', () => {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1', updatedBy: 'user-1',
}; };
@@ -4,7 +4,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -38,6 +38,8 @@ export type ListCustomersQuery = {
readonly nfcId?: string; readonly nfcId?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -85,6 +87,8 @@ export class CustomersService {
nfcId: query.nfcId, nfcId: query.nfcId,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -350,8 +354,8 @@ export class CustomersService {
status: customer.status.value, status: customer.status.value,
createdAt: customer.createdAt.value, createdAt: customer.createdAt.value,
updatedAt: customer.updatedAt.value, updatedAt: customer.updatedAt.value,
createdBy: customer.createdBy, createdBy: pickUserRelation(customer.createdByUser),
updatedBy: customer.updatedBy, updatedBy: pickUserRelation(customer.updatedByUser),
}; };
} }
@@ -15,7 +15,10 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { import {
CONTACT_JOB_TITLE_MAX_LENGTH, CONTACT_JOB_TITLE_MAX_LENGTH,
@@ -347,9 +350,9 @@ export class CustomerDto {
@ApiProperty({ description: 'Unix ms' }) @ApiProperty({ description: 'Unix ms' })
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -10,6 +11,8 @@ export type Division = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type CreateDivisionInput = { export type CreateDivisionInput = {
@@ -30,6 +33,8 @@ export type ListDivisionsFilters = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -43,7 +43,13 @@ describe('DivisionsRepository', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy })); where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit })); orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset })); limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]); offset.mockResolvedValue([row]);
@@ -59,7 +65,6 @@ describe('DivisionsRepository', () => {
update.mockReturnValue({ set }); update.mockReturnValue({ set });
del.mockReturnValue({ where }); del.mockReturnValue({ where });
returning.mockResolvedValue([row]); returning.mockResolvedValue([row]);
where.mockImplementation(() => ({ limit, orderBy, returning }));
const moduleRef: TestingModule = await Test.createTestingModule({ const moduleRef: TestingModule = await Test.createTestingModule({
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }], providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
@@ -4,10 +4,12 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { divisions, type DivisionRow } from '../../../database/schema'; import { divisions, type DivisionRow } from '../../../database/schema';
import type { import type {
CreateDivisionInput, CreateDivisionInput,
@@ -16,6 +18,15 @@ import type {
UpdateDivisionInput, UpdateDivisionInput,
} from './division'; } 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() @Injectable()
export class DivisionsRepository { export class DivisionsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -34,12 +45,16 @@ export class DivisionsRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(divisions.code)) .orderBy(
...toOrderClauses(DIVISION_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row)), data: await this.hydrate(rows),
total: Number(totalRow?.total ?? 0), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -59,7 +74,7 @@ export class DivisionsRepository {
.where(eq(divisions.id, id)) .where(eq(divisions.id, id))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row) : null; return row ? this.hydrateOne(row) : null;
} }
async findByCode(code: string): Promise<Division | null> { async findByCode(code: string): Promise<Division | null> {
@@ -69,7 +84,7 @@ export class DivisionsRepository {
.where(eq(divisions.code, code)) .where(eq(divisions.code, code))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row) : null; return row ? this.hydrateOne(row) : null;
} }
async create(input: CreateDivisionInput): Promise<Division> { async create(input: CreateDivisionInput): Promise<Division> {
@@ -89,7 +104,7 @@ export class DivisionsRepository {
}) })
.returning(); .returning();
const row = inserted[0]; const row = inserted[0];
return this.toDomain(row); return this.hydrateOne(row);
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -139,7 +154,7 @@ export class DivisionsRepository {
.where(eq(divisions.id, id)) .where(eq(divisions.id, id))
.returning(); .returning();
const row = updated[0]; const row = updated[0];
return this.toDomain(row); return this.hydrateOne(row);
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -164,7 +179,7 @@ export class DivisionsRepository {
if (!row) { if (!row) {
throw new NotFoundException('Division not found'); throw new NotFoundException('Division not found');
} }
return this.toDomain(row); return this.hydrateOne(row);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -243,17 +258,25 @@ export class DivisionsRepository {
return parts.length === 1 ? parts[0] : and(...parts); return parts.length === 1 ? parts[0] : and(...parts);
} }
private toDomain(row: DivisionRow): Division { private async hydrate(rows: DivisionRow[]): Promise<Division[]> {
return { return attachAuditUsers(
id: row.id, this.db,
name: row.name, rows.map((row) => ({
code: row.code, id: row.id,
status: Status.create(row.status), name: row.name,
createdAt: DateTime.fromUnixMs(row.createdAt), code: row.code,
updatedAt: DateTime.fromUnixMs(row.updatedAt), status: Status.create(row.status),
createdBy: row.createdBy, createdAt: DateTime.fromUnixMs(row.createdAt),
updatedBy: row.updatedBy, updatedAt: DateTime.fromUnixMs(row.updatedAt),
}; createdBy: row.createdBy,
updatedBy: row.updatedBy,
})),
);
}
private async hydrateOne(row: DivisionRow): Promise<Division> {
const [item] = await this.hydrate([row]);
return item;
} }
private rethrowUniqueViolation(error: unknown): never { private rethrowUniqueViolation(error: unknown): never {
@@ -33,6 +33,8 @@ describe('DivisionsService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -68,6 +70,7 @@ describe('DivisionsService', () => {
code: 'FIN', code: 'FIN',
status: 'draft', status: 'draft',
createdAt: now.value, createdAt: now.value,
createdBy: { id: 'user-1', username: 'admin' },
}); });
expect(service.visibleFields).toContain('status'); expect(service.visibleFields).toContain('status');
}); });
@@ -4,7 +4,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { Status } from '../../../common/value-objects/status/status';
import type { import type {
CreateDivisionInput, CreateDivisionInput,
@@ -19,6 +19,8 @@ export type ListDivisionsQuery = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -48,6 +50,8 @@ export class DivisionsService {
code: query.code, code: query.code,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -221,8 +225,8 @@ export class DivisionsService {
status: division.status.value, status: division.status.value,
createdAt: division.createdAt.value, createdAt: division.createdAt.value,
updatedAt: division.updatedAt.value, updatedAt: division.updatedAt.value,
createdBy: division.createdBy, createdBy: pickUserRelation(division.createdByUser),
updatedBy: division.updatedBy, updatedBy: pickUserRelation(division.updatedByUser),
}; };
} }
@@ -10,7 +10,10 @@ import {
Matches, Matches,
MaxLength, MaxLength,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { import {
DIVISION_CODE_MAX_LENGTH, DIVISION_CODE_MAX_LENGTH,
@@ -19,6 +22,15 @@ import {
DIVISION_NAME_PATTERN, DIVISION_NAME_PATTERN,
} from '../division-fields'; } from '../division-fields';
export const DIVISION_ORDER_FIELDS = [
'id',
'name',
'code',
'status',
'createdAt',
'updatedAt',
] as const;
export class CreateDivisionDto { export class CreateDivisionDto {
@ApiProperty({ @ApiProperty({
example: 'Human Resources', example: 'Human Resources',
@@ -138,9 +150,9 @@ export class DivisionDto {
@ApiProperty({ description: 'Unix ms' }) @ApiProperty({ description: 'Unix ms' })
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -247,11 +247,11 @@ export class EmployeeDto {
@ApiProperty({ description: 'Unix ms' }) @ApiProperty({ description: 'Unix ms' })
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
@ApiPropertyOptional({ type: UserRelationDto, nullable: true }) @ApiPropertyOptional({ type: UserRelationDto, nullable: true })
user!: UserRelationDto | null; user!: UserRelationDto | null;
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -14,6 +15,8 @@ export type Employee = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
readonly userId: string | null; readonly userId: string | null;
readonly user: { readonly id: string; readonly username: string } | null; readonly user: { readonly id: string; readonly username: string } | null;
}; };
@@ -51,6 +54,8 @@ export type ListEmployeesFilters = {
readonly status?: string; readonly status?: string;
readonly userId?: string; readonly userId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -57,7 +57,13 @@ describe('EmployeesRepository', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning })); where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit })); orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset })); limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]); offset.mockResolvedValue([row]);
@@ -123,14 +129,14 @@ describe('EmployeesRepository', () => {
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
from: () => ({ from: () => ({
$dynamic: () => ({ $dynamic: () => ({
where: () => ({ where: () => ({
orderBy: () => ({ orderBy: () => ({
limit: () => ({ limit: () => ({
offset: () => Promise.resolve([row]), offset: () => Promise.resolve([row]),
}),
}), }),
}), }),
}), }),
}),
}), }),
})); }));
@@ -4,7 +4,9 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -19,6 +21,17 @@ import type {
UpdateEmployeeInput, UpdateEmployeeInput,
} from './employee'; } 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() @Injectable()
export class EmployeesRepository { export class EmployeesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -37,16 +50,21 @@ export class EmployeesRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(employees.code)) .orderBy(
...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { const mapped = await Promise.all(
data: await Promise.all( rows.map(async (row) =>
rows.map(async (row) => this.hydrateOne(row, await this.loadAssignedUser(row.userId)),
this.toDomain(row, await this.loadAssignedUser(row.userId)),
),
), ),
);
return {
data: mapped,
total: Number(totalRow?.total ?? 0), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -67,7 +85,7 @@ export class EmployeesRepository {
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row return row
? this.toDomain(row, await this.loadAssignedUser(row.userId)) ? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null; : null;
} }
@@ -79,7 +97,7 @@ export class EmployeesRepository {
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row return row
? this.toDomain(row, await this.loadAssignedUser(row.userId)) ? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null; : null;
} }
@@ -91,7 +109,7 @@ export class EmployeesRepository {
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row return row
? this.toDomain(row, await this.loadAssignedUser(row.userId)) ? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null; : null;
} }
@@ -104,7 +122,7 @@ export class EmployeesRepository {
.values(this.toInsertValues(input, status, now, input.userId)) .values(this.toInsertValues(input, status, now, input.userId))
.returning(); .returning();
const row = inserted[0]; const row = inserted[0];
return this.toDomain(row, await this.loadAssignedUser(row.userId)); return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -153,7 +171,7 @@ export class EmployeesRepository {
.where(eq(employees.id, id)) .where(eq(employees.id, id))
.returning(); .returning();
const row = updated[0]; const row = updated[0];
return this.toDomain(row, await this.loadAssignedUser(row.userId)); return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
} }
@@ -178,7 +196,7 @@ export class EmployeesRepository {
if (!row) { if (!row) {
throw new NotFoundException('Employee not found'); 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( async bulkUpdateStatus(
@@ -308,7 +326,7 @@ export class EmployeesRepository {
private toDomain( private toDomain(
row: EmployeeRow, row: EmployeeRow,
user: { id: string; username: string } | null, user: { id: string; username: string } | null,
): Employee { ) {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -325,6 +343,14 @@ export class EmployeesRepository {
}; };
} }
private async hydrateOne(
row: EmployeeRow,
user: { id: string; username: string } | null,
): Promise<Employee> {
const [item] = await attachAuditUsers(this.db, [this.toDomain(row, user)]);
return item;
}
private rethrowUniqueViolation(error: unknown): never { private rethrowUniqueViolation(error: unknown): never {
const err = this.unwrapDbError(error); const err = this.unwrapDbError(error);
if (err.code === '23505') { if (err.code === '23505') {
@@ -45,6 +45,8 @@ describe('EmployeesService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
userId: null, userId: null,
user: null, user: null,
}; };
@@ -5,7 +5,12 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number'; import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -33,6 +38,8 @@ export type ListEmployeesQuery = {
readonly status?: string; readonly status?: string;
readonly userId?: string; readonly userId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -73,6 +80,8 @@ export class EmployeesService {
status: query.status, status: query.status,
userId: query.userId, userId: query.userId,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -284,9 +293,9 @@ export class EmployeesService {
status: employee.status.value, status: employee.status.value,
createdAt: employee.createdAt.value, createdAt: employee.createdAt.value,
updatedAt: employee.updatedAt.value, updatedAt: employee.updatedAt.value,
createdBy: employee.createdBy, createdBy: pickUserRelation(employee.createdByUser),
updatedBy: employee.updatedBy, updatedBy: pickUserRelation(employee.updatedByUser),
user: employee.user, user: pickRelation(employee.user, USER_RELATION_FIELDS),
}; };
} }
@@ -10,7 +10,10 @@ import {
Matches, Matches,
MaxLength, MaxLength,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { import {
PRODUCT_BRAND_MAX_LENGTH, PRODUCT_BRAND_MAX_LENGTH,
@@ -200,9 +203,9 @@ export class ProductDto {
@ApiProperty({ description: 'Unix ms' }) @ApiProperty({ description: 'Unix ms' })
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -14,6 +15,8 @@ export type Product = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type CreateProductInput = { export type CreateProductInput = {
@@ -42,6 +45,8 @@ export type ListProductsFilters = {
readonly brand?: string; readonly brand?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -56,7 +56,13 @@ describe('ProductsRepository', () => {
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks(); jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning })); where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit })); orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset })); limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]); offset.mockResolvedValue([row]);
@@ -4,7 +4,9 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -17,6 +19,17 @@ import type {
UpdateProductInput, UpdateProductInput,
} from './product'; } 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() @Injectable()
export class ProductsRepository { export class ProductsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -35,12 +48,16 @@ export class ProductsRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(products.code)) .orderBy(
...toOrderClauses(PRODUCT_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row)), data: await this.hydrate(rows),
total: Number(totalRow?.total ?? 0), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -60,7 +77,7 @@ export class ProductsRepository {
.where(eq(products.id, id)) .where(eq(products.id, id))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row) : null; return row ? await this.hydrateOne(row) : null;
} }
async findByCode(code: string): Promise<Product | null> { async findByCode(code: string): Promise<Product | null> {
@@ -70,7 +87,7 @@ export class ProductsRepository {
.where(eq(products.code, code)) .where(eq(products.code, code))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row) : null; return row ? await this.hydrateOne(row) : null;
} }
async create(input: CreateProductInput): Promise<Product> { async create(input: CreateProductInput): Promise<Product> {
@@ -82,7 +99,7 @@ export class ProductsRepository {
.values(this.toInsertValues(input, status, now, input.userId)) .values(this.toInsertValues(input, status, now, input.userId))
.returning(); .returning();
const row = inserted[0]; const row = inserted[0];
return this.toDomain(row); return await this.hydrateOne(row);
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
} }
@@ -132,7 +149,7 @@ export class ProductsRepository {
.where(eq(products.id, id)) .where(eq(products.id, id))
.returning(); .returning();
const row = updated[0]; const row = updated[0];
return this.toDomain(row); return await this.hydrateOne(row);
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
} }
@@ -157,7 +174,7 @@ export class ProductsRepository {
if (!row) { if (!row) {
throw new NotFoundException('Product not found'); throw new NotFoundException('Product not found');
} }
return this.toDomain(row); return await this.hydrateOne(row);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -262,7 +279,7 @@ export class ProductsRepository {
}; };
} }
private toDomain(row: ProductRow): Product { private toDomain(row: ProductRow) {
return { return {
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -278,6 +295,18 @@ export class ProductsRepository {
}; };
} }
private async hydrate(rows: ProductRow[]): Promise<Product[]> {
return attachAuditUsers(
this.db,
rows.map((row) => this.toDomain(row)),
);
}
private async hydrateOne(row: ProductRow): Promise<Product> {
const [item] = await this.hydrate([row]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error); const err = this.unwrapDbError(error);
if (err.code === '23505') { if (err.code === '23505') {
@@ -36,6 +36,8 @@ describe('ProductsService', () => {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1', updatedBy: 'user-1',
}; };
@@ -4,7 +4,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { Decimal } from '../../../common/value-objects/decimal/decimal';
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error'; import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -29,6 +29,8 @@ export type ListProductsQuery = {
readonly brand?: string; readonly brand?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -65,6 +67,8 @@ export class ProductsService {
brand: query.brand, brand: query.brand,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -240,8 +244,8 @@ export class ProductsService {
status: product.status.value, status: product.status.value,
createdAt: product.createdAt.value, createdAt: product.createdAt.value,
updatedAt: product.updatedAt.value, updatedAt: product.updatedAt.value,
createdBy: product.createdBy, createdBy: pickUserRelation(product.createdByUser),
updatedBy: product.updatedBy, updatedBy: pickUserRelation(product.updatedByUser),
}; };
} }
+12
View File
@@ -1,3 +1,7 @@
import type {
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
@@ -7,6 +11,7 @@ import type { WeekdaysInput } from '../shared/field-fields';
export type CycleDestination = { export type CycleDestination = {
readonly id: string; readonly id: string;
readonly customerId: string; readonly customerId: string;
readonly customer: DefaultRelation | null;
readonly sortOrder: number; readonly sortOrder: number;
}; };
@@ -15,6 +20,8 @@ export type CycleWeekday = {
readonly weekday: WeekdayName; readonly weekday: WeekdayName;
readonly startBranchId: string; readonly startBranchId: string;
readonly endBranchId: string; readonly endBranchId: string;
readonly startBranch: DefaultRelation | null;
readonly endBranch: DefaultRelation | null;
readonly routeGeometry: RouteGeometry; readonly routeGeometry: RouteGeometry;
readonly destinations: readonly CycleDestination[]; readonly destinations: readonly CycleDestination[];
}; };
@@ -22,6 +29,7 @@ export type CycleWeekday = {
export type Cycle = { export type Cycle = {
readonly id: string; readonly id: string;
readonly employeeId: string; readonly employeeId: string;
readonly employee: DefaultRelation | null;
readonly purpose: FieldPurpose; readonly purpose: FieldPurpose;
readonly cycleNumber: number; readonly cycleNumber: number;
readonly weekdays: readonly CycleWeekday[]; readonly weekdays: readonly CycleWeekday[];
@@ -30,6 +38,8 @@ export type Cycle = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type CreateCycleInput = { export type CreateCycleInput = {
@@ -55,6 +65,8 @@ export type ListCyclesFilters = {
readonly cycleNumber?: number; readonly cycleNumber?: number;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly purposes?: readonly string[]; readonly purposes?: readonly string[];
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
+81 -6
View File
@@ -16,6 +16,14 @@ import {
sql, sql,
SQL, SQL,
} from 'drizzle-orm'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
@@ -30,6 +38,16 @@ import {
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose'; import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle'; 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<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable() @Injectable()
@@ -48,11 +66,15 @@ export class CyclesRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(cycles.cycleNumber)) .orderBy(
...toOrderClauses(CYCLE_ORDER_COLUMNS, filters, [
{ column: 'cycleNumber', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [], [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -77,7 +99,7 @@ export class CyclesRepository {
this.db, this.db,
weekdays.map((weekday) => weekday.id), weekdays.map((weekday) => weekday.id),
); );
return this.toDomain(row, weekdays, destinations); return this.hydrateOne(row, weekdays, destinations);
} }
async findLiveByKey( async findLiveByKey(
@@ -101,7 +123,7 @@ export class CyclesRepository {
.where(and(...parts)) .where(and(...parts))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row, [], []) : null; return row ? this.hydrateOne(row, [], []) : null;
} }
async listActiveByEmployeePurpose( async listActiveByEmployeePurpose(
@@ -126,7 +148,7 @@ export class CyclesRepository {
this.db, this.db,
weekdays.map((weekday) => weekday.id), weekdays.map((weekday) => weekday.id),
); );
result.push(this.toDomain(row, weekdays, destinations)); result.push(await this.hydrateOne(row, weekdays, destinations));
} }
return result; return result;
} }
@@ -291,7 +313,7 @@ export class CyclesRepository {
executor, executor,
weekdays.map((weekday) => weekday.id), weekdays.map((weekday) => weekday.id),
); );
return this.toDomain(row, weekdays, destinations); return this.hydrateOne(row, weekdays, destinations);
} }
private async replaceWeekdays( private async replaceWeekdays(
@@ -406,11 +428,14 @@ export class CyclesRepository {
weekday: weekday.weekday as WeekdayName, weekday: weekday.weekday as WeekdayName,
startBranchId: weekday.startBranchId, startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId, endBranchId: weekday.endBranchId,
startBranch: null,
endBranch: null,
routeGeometry: weekday.routeGeometry, routeGeometry: weekday.routeGeometry,
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map( destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
(destination) => ({ (destination) => ({
id: destination.id, id: destination.id,
customerId: destination.customerId, customerId: destination.customerId,
customer: null,
sortOrder: destination.sortOrder, sortOrder: destination.sortOrder,
}), }),
), ),
@@ -420,9 +445,59 @@ export class CyclesRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, updatedBy: row.updatedBy,
employee: null,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
}; };
} }
private async hydrate(items: Cycle[]): Promise<Cycle[]> {
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<Cycle> {
const [item] = await this.hydrate([
this.toDomain(row, weekdays, destinations),
]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error); const err = this.unwrapDbError(error);
if (err.code === '23505') { if (err.code === '23505') {
@@ -62,8 +62,17 @@ describe('CyclesService', () => {
weekday: 'monday', weekday: 'monday',
startBranchId: 'br-1', startBranchId: 'br-1',
endBranchId: 'br-2', endBranchId: 'br-2',
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
routeGeometry: geometry, 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'), status: Status.create('draft'),
@@ -71,6 +80,9 @@ describe('CyclesService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 }; const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
+16 -7
View File
@@ -7,7 +7,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user'; import type { AuthUser } from '../../../common/auth/auth-user';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service'; import { BranchesService } from '../../configuration/branches/branches.service';
@@ -40,6 +45,8 @@ export type ListCyclesQuery = {
readonly cycleNumber?: number; readonly cycleNumber?: number;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -93,6 +100,8 @@ export class CyclesService {
cycleNumber: query.cycleNumber, cycleNumber: query.cycleNumber,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
purposes: query.purpose ? undefined : purposes, purposes: query.purpose ? undefined : purposes,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
@@ -237,26 +246,26 @@ export class CyclesService {
toItem(cycle: Cycle) { toItem(cycle: Cycle) {
return { return {
id: cycle.id, id: cycle.id,
employeeId: cycle.employeeId, employee: pickRelation(cycle.employee, DEFAULT_RELATION_FIELDS),
purpose: cycle.purpose, purpose: cycle.purpose,
cycleNumber: cycle.cycleNumber, cycleNumber: cycle.cycleNumber,
weekdays: cycle.weekdays.map((weekday) => ({ weekdays: cycle.weekdays.map((weekday) => ({
id: weekday.id, id: weekday.id,
weekday: weekday.weekday, weekday: weekday.weekday,
startBranchId: weekday.startBranchId, startBranch: pickRelation(weekday.startBranch, DEFAULT_RELATION_FIELDS),
endBranchId: weekday.endBranchId, endBranch: pickRelation(weekday.endBranch, DEFAULT_RELATION_FIELDS),
routeGeometry: weekday.routeGeometry, routeGeometry: weekday.routeGeometry,
destinations: weekday.destinations.map((destination) => ({ destinations: weekday.destinations.map((destination) => ({
id: destination.id, id: destination.id,
customerId: destination.customerId, customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
sortOrder: destination.sortOrder, sortOrder: destination.sortOrder,
})), })),
})), })),
status: cycle.status.value, status: cycle.status.value,
createdAt: cycle.createdAt.value, createdAt: cycle.createdAt.value,
updatedAt: cycle.updatedAt.value, updatedAt: cycle.updatedAt.value,
createdBy: cycle.createdBy, createdBy: pickUserRelation(cycle.createdByUser),
updatedBy: cycle.updatedBy, updatedBy: pickUserRelation(cycle.updatedByUser),
}; };
} }
+17 -13
View File
@@ -13,7 +13,11 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose'; import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto'; import { RouteGeometryDto } from '../../shared/route-geometry.dto';
@@ -143,8 +147,8 @@ export class CycleDestinationDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
id!: string; id!: string;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiProperty() @ApiProperty()
sortOrder!: number; sortOrder!: number;
@@ -157,11 +161,11 @@ export class CycleWeekdayDto {
@ApiProperty({ enum: WEEKDAY_NAMES }) @ApiProperty({ enum: WEEKDAY_NAMES })
weekday!: string; weekday!: string;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
startBranchId!: string; startBranch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
endBranchId!: string; endBranch!: DefaultRelationDto | null;
@ApiProperty({ type: RouteGeometryDto }) @ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto; routeGeometry!: RouteGeometryDto;
@@ -174,8 +178,8 @@ export class CycleDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
id!: string; id!: string;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
employeeId!: string; employee!: DefaultRelationDto | null;
@ApiProperty({ enum: FIELD_PURPOSES }) @ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string; purpose!: string;
@@ -195,11 +199,11 @@ export class CycleDto {
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
void ValidateNested; void ValidateNested;
+22 -17
View File
@@ -10,7 +10,12 @@ import {
IsUUID, IsUUID,
Matches, Matches,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES } from '../../shared/field-purpose'; import { FIELD_PURPOSES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto'; import { RouteGeometryDto } from '../../shared/route-geometry.dto';
@@ -199,8 +204,8 @@ export class PlanDestinationDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
id!: string; id!: string;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiProperty() @ApiProperty()
sortOrder!: number; sortOrder!: number;
@@ -210,8 +215,8 @@ export class PlanDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
id!: string; id!: string;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
employeeId!: string; employee!: DefaultRelationDto | null;
@ApiProperty({ enum: FIELD_PURPOSES }) @ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string; purpose!: string;
@@ -219,11 +224,11 @@ export class PlanDto {
@ApiProperty() @ApiProperty()
date!: number; date!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
startBranchId!: string; startBranch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
endBranchId!: string; endBranch!: DefaultRelationDto | null;
@ApiProperty({ type: RouteGeometryDto }) @ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto; routeGeometry!: RouteGeometryDto;
@@ -231,11 +236,11 @@ export class PlanDto {
@ApiProperty({ type: [PlanDestinationDto] }) @ApiProperty({ type: [PlanDestinationDto] })
destinations!: PlanDestinationDto[]; destinations!: PlanDestinationDto[];
@ApiProperty({ type: [String] }) @ApiProperty({ type: [CodeRelationDto] })
invoiceIds!: string[]; invoices!: CodeRelationDto[];
@ApiProperty({ type: [String] }) @ApiProperty({ type: [CodeRelationDto] })
packingSlipIds!: string[]; packingSlips!: CodeRelationDto[];
@ApiProperty({ enum: CORE_STATUSES }) @ApiProperty({ enum: CORE_STATUSES })
status!: string; status!: string;
@@ -246,11 +251,11 @@ export class PlanDto {
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
void Type; void Type;
+15
View File
@@ -1,3 +1,8 @@
import type {
CodeRelation,
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose } from '../shared/field-purpose'; import type { FieldPurpose } from '../shared/field-purpose';
@@ -6,25 +11,33 @@ import type { RouteGeometry } from '../shared/route-line-string';
export type PlanDestination = { export type PlanDestination = {
readonly id: string; readonly id: string;
readonly customerId: string; readonly customerId: string;
readonly customer: DefaultRelation | null;
readonly sortOrder: number; readonly sortOrder: number;
}; };
export type Plan = { export type Plan = {
readonly id: string; readonly id: string;
readonly employeeId: string; readonly employeeId: string;
readonly employee: DefaultRelation | null;
readonly purpose: FieldPurpose; readonly purpose: FieldPurpose;
readonly date: DateTime; readonly date: DateTime;
readonly startBranchId: string; readonly startBranchId: string;
readonly endBranchId: string; readonly endBranchId: string;
readonly startBranch: DefaultRelation | null;
readonly endBranch: DefaultRelation | null;
readonly routeGeometry: RouteGeometry; readonly routeGeometry: RouteGeometry;
readonly destinations: readonly PlanDestination[]; readonly destinations: readonly PlanDestination[];
readonly invoiceIds: readonly string[]; readonly invoiceIds: readonly string[];
readonly packingSlipIds: readonly string[]; readonly packingSlipIds: readonly string[];
readonly invoices: readonly CodeRelation[];
readonly packingSlips: readonly CodeRelation[];
readonly status: Status; readonly status: Status;
readonly createdAt: DateTime; readonly createdAt: DateTime;
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type PersistablePlanDestination = { export type PersistablePlanDestination = {
@@ -37,6 +50,8 @@ export type ListPlansFilters = {
readonly date?: number; readonly date?: number;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly purposes?: readonly string[]; readonly purposes?: readonly string[];
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
+97 -6
View File
@@ -16,6 +16,16 @@ import {
sql, sql,
SQL, SQL,
} from 'drizzle-orm'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module'; 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 { RouteGeometry } from '../shared/route-line-string';
import type { ListPlansFilters, Plan } from './plan'; 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<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable() @Injectable()
@@ -51,11 +71,17 @@ export class PlansRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(plans.date)) .orderBy(
...toOrderClauses(PLAN_ORDER_COLUMNS, filters, [
{ column: 'date', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [], [], [])), data: await this.hydrate(
rows.map((row) => this.toDomain(row, [], [], [])),
),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -99,7 +125,7 @@ export class PlansRepository {
.where(and(...parts)) .where(and(...parts))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
return row ? this.toDomain(row, [], [], []) : null; return row ? this.hydrateOne(row, [], [], []) : null;
} }
async create(input: { async create(input: {
@@ -327,7 +353,7 @@ export class PlansRepository {
.select() .select()
.from(planPackingSlips) .from(planPackingSlips)
.where(eq(planPackingSlips.planId, row.id)); .where(eq(planPackingSlips.planId, row.id));
return this.toDomain(row, destinations, invoices, packingSlips); return this.hydrateOne(row, destinations, invoices, packingSlips);
} }
private async replaceChildren( private async replaceChildren(
@@ -423,21 +449,86 @@ export class PlansRepository {
startBranchId: row.startBranchId, startBranchId: row.startBranchId,
endBranchId: row.endBranchId, endBranchId: row.endBranchId,
routeGeometry: row.routeGeometry, routeGeometry: row.routeGeometry,
startBranch: null,
endBranch: null,
employee: null,
destinations: destinationRows.map((destination) => ({ destinations: destinationRows.map((destination) => ({
id: destination.id, id: destination.id,
customerId: destination.customerId, customerId: destination.customerId,
customer: null,
sortOrder: destination.sortOrder, sortOrder: destination.sortOrder,
})), })),
invoiceIds: invoiceRows.map((row) => row.invoiceId), invoiceIds: invoiceRows.map((invoice) => invoice.invoiceId),
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId), packingSlipIds: packingSlipRows.map((slip) => slip.packingSlipId),
invoices: [],
packingSlips: [],
status: Status.create(row.status), status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt), createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, updatedBy: row.updatedBy,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
}; };
} }
private async hydrate(items: Plan[]): Promise<Plan[]> {
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<typeof value> => Boolean(value)),
packingSlips: item.packingSlipIds
.map((id) => packing.get(id))
.filter((value): value is NonNullable<typeof value> => Boolean(value)),
}));
}
private async hydrateOne(
row: PlanRow,
destinations: PlanDestinationRow[],
invoices: PlanInvoiceRow[],
packingSlips: PlanPackingSlipRow[],
): Promise<Plan> {
const [item] = await this.hydrate([
this.toDomain(row, destinations, invoices, packingSlips),
]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error); const err = this.unwrapDbError(error);
if (err.code === '23505') { if (err.code === '23505') {
+34 -3
View File
@@ -68,8 +68,17 @@ describe('PlansService', () => {
weekday: 'monday', weekday: 'monday',
startBranchId: 'br-1', startBranchId: 'br-1',
endBranchId: 'br-2', endBranchId: 'br-2',
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
routeGeometry: geometry, 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'), status: Status.create('active'),
@@ -77,6 +86,9 @@ describe('PlansService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 = { const plan: Plan = {
@@ -87,14 +99,28 @@ describe('PlansService', () => {
startBranchId: 'br-1', startBranchId: 'br-1',
endBranchId: 'br-2', endBranchId: 'br-2',
routeGeometry: geometry, 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: [], invoiceIds: [],
packingSlipIds: [], 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'), status: Status.create('active'),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -196,7 +222,12 @@ describe('PlansService', () => {
...plan, ...plan,
destinations: [ destinations: [
...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); await service.addDestination('pln-1', 'cus-2', undefined, user);
+18 -9
View File
@@ -7,7 +7,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user'; import type { AuthUser } from '../../../common/auth/auth-user';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error'; import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
@@ -40,6 +45,8 @@ export type ListPlansQuery = {
readonly date?: string; readonly date?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -96,6 +103,8 @@ export class PlansService {
: undefined, : undefined,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
purposes: query.purpose ? undefined : purposes, purposes: query.purpose ? undefined : purposes,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
@@ -390,24 +399,24 @@ export class PlansService {
toItem(plan: Plan) { toItem(plan: Plan) {
return { return {
id: plan.id, id: plan.id,
employeeId: plan.employeeId, employee: pickRelation(plan.employee, DEFAULT_RELATION_FIELDS),
purpose: plan.purpose, purpose: plan.purpose,
date: plan.date.value, date: plan.date.value,
startBranchId: plan.startBranchId, startBranch: pickRelation(plan.startBranch, DEFAULT_RELATION_FIELDS),
endBranchId: plan.endBranchId, endBranch: pickRelation(plan.endBranch, DEFAULT_RELATION_FIELDS),
routeGeometry: plan.routeGeometry, routeGeometry: plan.routeGeometry,
destinations: plan.destinations.map((destination) => ({ destinations: plan.destinations.map((destination) => ({
id: destination.id, id: destination.id,
customerId: destination.customerId, customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
sortOrder: destination.sortOrder, sortOrder: destination.sortOrder,
})), })),
invoiceIds: [...plan.invoiceIds], invoices: [...plan.invoices],
packingSlipIds: [...plan.packingSlipIds], packingSlips: [...plan.packingSlips],
status: plan.status.value, status: plan.status.value,
createdAt: plan.createdAt.value, createdAt: plan.createdAt.value,
updatedAt: plan.updatedAt.value, updatedAt: plan.updatedAt.value,
createdBy: plan.createdBy, createdBy: pickUserRelation(plan.createdByUser),
updatedBy: plan.updatedBy, updatedBy: pickUserRelation(plan.updatedByUser),
}; };
} }
+8 -5
View File
@@ -12,7 +12,10 @@ import {
MaxLength, MaxLength,
ValidateNested, ValidateNested,
} from 'class-validator'; } 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 { CORE_STATUSES } from '../../../common/value-objects/status/status';
import { PRIVILEGE_ACTIONS } from '../privilege-action'; import { PRIVILEGE_ACTIONS } from '../privilege-action';
@@ -178,11 +181,11 @@ export class PrivilegeDto {
@ApiProperty({ description: 'Unix ms' }) @ApiProperty({ description: 'Unix ms' })
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
export class PrivilegeDetailResponseDto extends PrivilegeDto { export class PrivilegeDetailResponseDto extends PrivilegeDto {
+7
View File
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../common/http/response';
import { DateTime } from '../../common/value-objects/date-time/date-time'; import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status'; import { Status } from '../../common/value-objects/status/status';
import type { PrivilegeAction } from './privilege-action'; import type { PrivilegeAction } from './privilege-action';
@@ -21,6 +22,8 @@ export type Privilege = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type PrivilegeWithDetails = Privilege & { export type PrivilegeWithDetails = Privilege & {
@@ -60,12 +63,16 @@ export type ListPrivilegesFilters = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
export type ListPrivilegeKeysFilters = { export type ListPrivilegeKeysFilters = {
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -5,6 +5,8 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm'; 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 { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status'; import { Status } from '../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module'; import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
@@ -30,6 +32,22 @@ import type {
UpdatePrivilegeInput, UpdatePrivilegeInput,
} from './privilege'; } 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() @Injectable()
export class PrivilegesRepository { export class PrivilegesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -47,12 +65,16 @@ export class PrivilegesRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(privileges.code)) .orderBy(
...toOrderClauses(PRIVILEGE_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row)), data: await this.hydrate(rows),
total: Number(totalRow?.total ?? 0), total: Number(totalRow?.total ?? 0),
}; };
} }
@@ -75,7 +97,8 @@ export class PrivilegesRepository {
return null; return null;
} }
const details = await this.loadDetails(id); 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<Privilege | null> { async findByCode(code: string): Promise<Privilege | null> {
@@ -84,7 +107,7 @@ export class PrivilegesRepository {
.from(privileges) .from(privileges)
.where(eq(privileges.code, code)) .where(eq(privileges.code, code))
.limit(1); .limit(1);
return row ? this.toDomain(row) : null; return row ? this.hydrateOne(row) : null;
} }
async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> { async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> {
@@ -117,7 +140,8 @@ export class PrivilegesRepository {
} }
const details = await this.loadDetails(row.id, tx); const details = await this.loadDetails(row.id, tx);
return { ...this.toDomain(row), details }; const [base] = await this.hydrate([row]);
return { ...base, details };
}); });
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
@@ -190,7 +214,8 @@ export class PrivilegesRepository {
} }
const details = await this.loadDetails(id, tx); const details = await this.loadDetails(id, tx);
return { ...this.toDomain(row), details }; const [base] = await this.hydrate([row]);
return { ...base, details };
}); });
} catch (error) { } catch (error) {
this.rethrowUniqueViolation(error); this.rethrowUniqueViolation(error);
@@ -215,7 +240,7 @@ export class PrivilegesRepository {
if (!row) { if (!row) {
throw new NotFoundException('Privilege not found'); throw new NotFoundException('Privilege not found');
} }
return this.toDomain(row); return this.hydrateOne(row);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -297,7 +322,12 @@ export class PrivilegesRepository {
.select() .select()
.from(privilegeKeys) .from(privilegeKeys)
.where(where) .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) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
@@ -451,7 +481,7 @@ export class PrivilegesRepository {
})); }));
} }
private toDomain(row: PrivilegeRow): Privilege { private toDomain(row: PrivilegeRow) {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -464,6 +494,18 @@ export class PrivilegesRepository {
}; };
} }
private async hydrate(rows: PrivilegeRow[]): Promise<Privilege[]> {
return attachAuditUsers(
this.db,
rows.map((row) => this.toDomain(row)),
);
}
private async hydrateOne(row: PrivilegeRow): Promise<Privilege> {
const [item] = await this.hydrate([row]);
return item;
}
private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey { private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey {
return { return {
id: row.id, id: row.id,
@@ -36,6 +36,8 @@ describe('PrivilegesService', () => {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1', updatedBy: 'user-1',
details: [], details: [],
}; };
+11 -3
View File
@@ -4,7 +4,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../common/http/response'; 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 { Status } from '../../common/value-objects/status/status';
import type { PrivilegeAction } from './privilege-action'; import type { PrivilegeAction } from './privilege-action';
import { assertPrivilegeAction } from './privilege-action'; import { assertPrivilegeAction } from './privilege-action';
@@ -23,6 +23,8 @@ export type ListPrivilegesQuery = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -30,6 +32,8 @@ export type ListPrivilegesQuery = {
export type ListPrivilegeKeysQuery = { export type ListPrivilegeKeysQuery = {
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -59,6 +63,8 @@ export class PrivilegesService {
code: query.code, code: query.code,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -241,6 +247,8 @@ export class PrivilegesService {
const page = toListPage(query); const page = toListPage(query);
return this.privilegesRepository.listKeys({ return this.privilegesRepository.listKeys({
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -313,8 +321,8 @@ export class PrivilegesService {
status: privilege.status.value, status: privilege.status.value,
createdAt: privilege.createdAt.value, createdAt: privilege.createdAt.value,
updatedAt: privilege.updatedAt.value, updatedAt: privilege.updatedAt.value,
createdBy: privilege.createdBy, createdBy: pickUserRelation(privilege.createdByUser),
updatedBy: privilege.updatedBy, updatedBy: pickUserRelation(privilege.updatedByUser),
}; };
} }
@@ -15,7 +15,12 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response'; import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { import {
DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_ADDRESS_MAX_LENGTH,
DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH,
@@ -217,14 +222,12 @@ export class PackingSlipDto {
id!: string; id!: string;
@ApiProperty() @ApiProperty()
code!: string; code!: string;
@ApiPropertyOptional({ nullable: true, format: 'uuid' }) @ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
salesOrderId!: string | null; salesOrder!: CodeRelationDto | null;
@ApiPropertyOptional({ nullable: true })
salesOrderNumber!: string | null;
@ApiProperty() @ApiProperty()
date!: number; date!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiProperty() @ApiProperty()
address!: string; address!: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
@@ -239,8 +242,8 @@ export class PackingSlipDto {
createdAt!: number; createdAt!: number;
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,8 @@
import type {
CodeRelation,
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type PackingSlipLine = { export type PackingSlipLine = {
readonly id: string; readonly id: string;
readonly productId: string; readonly productId: string;
readonly product: DefaultRelation | null;
readonly quantity: Decimal; readonly quantity: Decimal;
readonly price: Decimal; readonly price: Decimal;
}; };
@@ -26,6 +32,10 @@ export type PackingSlip = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly salesOrder: CodeRelation | null;
readonly customer: DefaultRelation | null;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type PackingSlipLineInput = { export type PackingSlipLineInput = {
@@ -69,6 +79,8 @@ export type ListPackingSlipsFilters = {
readonly customerId?: string; readonly customerId?: string;
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -4,7 +4,16 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -26,6 +35,15 @@ import type {
UpdatePackingSlipInput, UpdatePackingSlipInput,
} from './packing-slip'; } 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<DrizzleDB, 'select' | 'insert' | 'delete'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable() @Injectable()
@@ -47,11 +65,15 @@ export class PackingSlipsRepository {
.select() .select()
.from(packingSlips) .from(packingSlips)
.where(where) .where(where)
.orderBy(asc(packingSlips.code)) .orderBy(
...toOrderClauses(PACKING_SLIP_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -67,7 +89,7 @@ export class PackingSlipsRepository {
return null; return null;
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
} }
async create(input: CreatePackingSlipInput): Promise<PackingSlip> { async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
@@ -90,7 +112,7 @@ export class PackingSlipsRepository {
const row = inserted[0]; const row = inserted[0];
await this.replaceProducts(tx, row.id, input.products); await this.replaceProducts(tx, row.id, input.products);
const products = await this.selectProducts(tx, row.id); const products = await this.selectProducts(tx, row.id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -153,7 +175,7 @@ export class PackingSlipsRepository {
await this.replaceProducts(tx, id, input.products); await this.replaceProducts(tx, id, input.products);
} }
const products = await this.selectProducts(tx, id); const products = await this.selectProducts(tx, id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -180,7 +202,7 @@ export class PackingSlipsRepository {
throw new NotFoundException('Packing slip not found'); throw new NotFoundException('Packing slip not found');
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -329,6 +351,7 @@ export class PackingSlipsRepository {
products: productRows.map((line) => ({ products: productRows.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, productId: line.productId,
product: null,
quantity: Decimal.create(line.quantity), quantity: Decimal.create(line.quantity),
price: Decimal.create(line.price), price: Decimal.create(line.price),
})), })),
@@ -337,9 +360,52 @@ export class PackingSlipsRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, updatedBy: row.updatedBy,
salesOrder: null,
customer: null,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
}; };
} }
private async hydrate(items: PackingSlip[]): Promise<PackingSlip[]> {
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<PackingSlip> {
const [item] = await this.hydrate([this.toDomain(row, products)]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
@@ -57,6 +57,7 @@ describe('PackingSlipsService', () => {
{ {
id: 'line-1', id: 'line-1',
productId: 'prd-1', productId: 'prd-1',
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
quantity: Decimal.create('2'), quantity: Decimal.create('2'),
price: Decimal.create('12500'), price: Decimal.create('12500'),
}, },
@@ -66,6 +67,10 @@ describe('PackingSlipsService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 = { const createBody = {
@@ -4,7 +4,13 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
@@ -42,6 +48,8 @@ export type ListPackingSlipsQuery = {
readonly customerId?: string; readonly customerId?: string;
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -70,6 +78,8 @@ export class PackingSlipsService {
customerId: query.customerId, customerId: query.customerId,
salesOrderId: query.salesOrderId, salesOrderId: query.salesOrderId,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -272,10 +282,9 @@ export class PackingSlipsService {
return { return {
id: item.id, id: item.id,
code: item.code, code: item.code,
salesOrderId: item.salesOrderId, salesOrder: pickCodeRelation(item.salesOrder),
salesOrderNumber: item.salesOrderNumber,
date: item.date.value, date: item.date.value,
customerId: item.customerId, customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
address: item.address, address: item.address,
latitude: item.latitude, latitude: item.latitude,
longitude: item.longitude, longitude: item.longitude,
@@ -283,8 +292,8 @@ export class PackingSlipsService {
status: item.status.value, status: item.status.value,
createdAt: item.createdAt.value, createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value, updatedAt: item.updatedAt.value,
createdBy: item.createdBy, createdBy: pickUserRelation(item.createdByUser),
updatedBy: item.updatedBy, updatedBy: pickUserRelation(item.updatedByUser),
}; };
} }
@@ -293,7 +302,7 @@ export class PackingSlipsService {
...this.toListItem(item), ...this.toListItem(item),
products: item.products.map((line) => ({ products: item.products.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
quantity: line.quantity.value, quantity: line.quantity.value,
price: line.price.value, price: line.price.value,
})), })),
@@ -331,7 +340,7 @@ export class PackingSlipsService {
salesOrderId: input.salesOrderId, salesOrderId: input.salesOrderId,
salesOrderNumber: source.code, salesOrderNumber: source.code,
date: input.date ?? DateTime.fromUnixMs(source.date).format(), date: input.date ?? DateTime.fromUnixMs(source.date).format(),
customerId: input.customerId ?? source.customerId, customerId: input.customerId ?? source.customer?.id ?? '',
address: input.address ?? source.address, address: input.address ?? source.address,
latitude: input.latitude !== undefined ? input.latitude : source.latitude, latitude: input.latitude !== undefined ? input.latitude : source.latitude,
longitude: longitude:
@@ -340,7 +349,7 @@ export class PackingSlipsService {
products: products:
input.products ?? input.products ??
source.products.map((line) => ({ source.products.map((line) => ({
productId: line.productId, productId: line.product?.id ?? '',
quantity: line.quantity, quantity: line.quantity,
price: line.price, price: line.price,
})), })),
@@ -12,7 +12,12 @@ import {
MaxLength, MaxLength,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response'; import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { import {
DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH,
DOCUMENT_CODE_PATTERN, DOCUMENT_CODE_PATTERN,
@@ -232,24 +237,20 @@ export class SalesInvoiceDto {
id!: string; id!: string;
@ApiProperty() @ApiProperty()
code!: string; code!: string;
@ApiPropertyOptional({ nullable: true, format: 'uuid' }) @ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
salesOrderId!: string | null; salesOrder!: CodeRelationDto | null;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
salesOrderCode!: string | null; packingSlip!: CodeRelationDto | null;
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
packingSlipId!: string | null;
@ApiPropertyOptional({ nullable: true })
packingSlipCode!: string | null;
@ApiProperty() @ApiProperty()
date!: number; date!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
salesPersonId!: string; salesPerson!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
branchId!: string; branch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
divisionId!: string; division!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
notes!: string | null; notes!: string | null;
@ApiProperty() @ApiProperty()
@@ -260,8 +261,8 @@ export class SalesInvoiceDto {
createdAt!: number; createdAt!: number;
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,8 @@
import type {
CodeRelation,
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type SalesInvoiceLine = { export type SalesInvoiceLine = {
readonly id: string; readonly id: string;
readonly productId: string; readonly productId: string;
readonly product: DefaultRelation | null;
readonly quantity: Decimal; readonly quantity: Decimal;
readonly price: Decimal; readonly price: Decimal;
}; };
@@ -29,6 +35,14 @@ export type SalesInvoice = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: 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 = { export type SalesInvoiceLineInput = {
@@ -80,6 +94,8 @@ export type ListSalesInvoicesFilters = {
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly packingSlipId?: string; readonly packingSlipId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -5,6 +5,15 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm'; 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -30,6 +39,15 @@ import type {
UpdateSalesInvoiceInput, UpdateSalesInvoiceInput,
} from './sales-invoice'; } 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<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
export type InvoiceTotals = { export type InvoiceTotals = {
@@ -57,11 +75,15 @@ export class SalesInvoicesRepository {
.select() .select()
.from(salesInvoices) .from(salesInvoices)
.where(where) .where(where)
.orderBy(asc(salesInvoices.code)) .orderBy(
...toOrderClauses(SALES_INVOICE_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -77,7 +99,7 @@ export class SalesInvoicesRepository {
return null; return null;
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
} }
async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> { async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
@@ -101,7 +123,7 @@ export class SalesInvoicesRepository {
await this.replaceProducts(tx, row.id, input.products); await this.replaceProducts(tx, row.id, input.products);
const products = await this.selectProducts(tx, row.id); const products = await this.selectProducts(tx, row.id);
const withBalance = await this.refreshBalance(tx, row.id, products); const withBalance = await this.refreshBalance(tx, row.id, products);
return this.toDomain(withBalance, products); return this.hydrateOne(withBalance, products);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -169,7 +191,7 @@ export class SalesInvoicesRepository {
} }
const products = await this.selectProducts(tx, id); const products = await this.selectProducts(tx, id);
const withBalance = await this.refreshBalance(tx, id, products); const withBalance = await this.refreshBalance(tx, id, products);
return this.toDomain(withBalance, products); return this.hydrateOne(withBalance, products);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -196,7 +218,7 @@ export class SalesInvoicesRepository {
throw new NotFoundException('Sales invoice not found'); throw new NotFoundException('Sales invoice not found');
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
return this.toDomain(row, products); return this.hydrateOne(row, products);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -249,7 +271,7 @@ export class SalesInvoicesRepository {
async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> { async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> {
const products = await this.selectProducts(this.db, invoiceId); const products = await this.selectProducts(this.db, invoiceId);
const row = await this.refreshBalance(this.db, invoiceId, products); const row = await this.refreshBalance(this.db, invoiceId, products);
return this.toDomain(row, products); return this.hydrateOne(row, products);
} }
private async totalsFrom( private async totalsFrom(
@@ -419,6 +441,7 @@ export class SalesInvoicesRepository {
products: productRows.map((line) => ({ products: productRows.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, productId: line.productId,
product: null,
quantity: Decimal.create(line.quantity), quantity: Decimal.create(line.quantity),
price: Decimal.create(line.price), price: Decimal.create(line.price),
})), })),
@@ -427,9 +450,58 @@ export class SalesInvoicesRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, 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<SalesInvoice[]> {
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<SalesInvoice> {
const [item] = await this.hydrate([this.toDomain(row, products)]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
@@ -72,6 +72,7 @@ describe('SalesInvoicesService', () => {
{ {
id: 'line-1', id: 'line-1',
productId: 'prd-1', productId: 'prd-1',
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
quantity: Decimal.create('2'), quantity: Decimal.create('2'),
price: Decimal.create('12500'), price: Decimal.create('12500'),
}, },
@@ -81,6 +82,14 @@ describe('SalesInvoicesService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 = { const createBody = {
@@ -134,15 +143,15 @@ describe('SalesInvoicesService', () => {
id: 'so-1', id: 'so-1',
code: 'SO-1', code: 'SO-1',
date: now.value, date: now.value,
salesPersonId: 'emp-1', salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
branchId: 'br-1', branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
divisionId: 'div-1', division: { id: 'div-1', code: 'FIN', name: 'Finance' },
customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
notes: 'from order', notes: 'from order',
products: [ products: [
{ {
id: 'ol-1', id: 'ol-1',
productId: 'prd-1', product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
quantity: '2.0000', quantity: '2.0000',
price: '12500.0000', price: '12500.0000',
}, },
@@ -4,7 +4,13 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
@@ -47,6 +53,8 @@ export type ListSalesInvoicesQuery = {
readonly salesOrderId?: string; readonly salesOrderId?: string;
readonly packingSlipId?: string; readonly packingSlipId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -89,6 +97,8 @@ export class SalesInvoicesService {
salesOrderId: query.salesOrderId, salesOrderId: query.salesOrderId,
packingSlipId: query.packingSlipId, packingSlipId: query.packingSlipId,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -316,22 +326,20 @@ export class SalesInvoicesService {
return { return {
id: item.id, id: item.id,
code: item.code, code: item.code,
salesOrderId: item.salesOrderId, salesOrder: pickCodeRelation(item.salesOrder),
salesOrderCode: item.salesOrderCode, packingSlip: pickCodeRelation(item.packingSlip),
packingSlipId: item.packingSlipId,
packingSlipCode: item.packingSlipCode,
date: item.date.value, date: item.date.value,
salesPersonId: item.salesPersonId, salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
branchId: item.branchId, branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
divisionId: item.divisionId, division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
customerId: item.customerId, customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
notes: item.notes, notes: item.notes,
balance: item.balance.value, balance: item.balance.value,
status: item.status.value, status: item.status.value,
createdAt: item.createdAt.value, createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value, updatedAt: item.updatedAt.value,
createdBy: item.createdBy, createdBy: pickUserRelation(item.createdByUser),
updatedBy: item.updatedBy, updatedBy: pickUserRelation(item.updatedByUser),
}; };
} }
@@ -340,7 +348,7 @@ export class SalesInvoicesService {
...this.toListItem(item), ...this.toListItem(item),
products: item.products.map((line) => ({ products: item.products.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
quantity: line.quantity.value, quantity: line.quantity.value,
price: line.price.value, price: line.price.value,
})), })),
@@ -374,15 +382,15 @@ export class SalesInvoicesService {
const order = await this.salesOrdersService.findById(input.salesOrderId); const order = await this.salesOrdersService.findById(input.salesOrderId);
salesOrderCode = order.code; salesOrderCode = order.code;
date = date || DateTime.fromUnixMs(order.date).format(); date = date || DateTime.fromUnixMs(order.date).format();
salesPersonId = salesPersonId || order.salesPersonId; salesPersonId = salesPersonId || order.salesPerson?.id || '';
branchId = branchId || order.branchId; branchId = branchId || order.branch?.id || '';
divisionId = divisionId || order.divisionId; divisionId = divisionId || order.division?.id || '';
customerId = customerId || order.customerId; customerId = customerId || order.customer?.id || '';
notes = notes !== undefined ? notes : order.notes; notes = notes !== undefined ? notes : order.notes;
products = products =
products ?? products ??
order.products.map((line) => ({ order.products.map((line) => ({
productId: line.productId, productId: line.product?.id ?? '',
quantity: line.quantity, quantity: line.quantity,
price: line.price, price: line.price,
})); }));
@@ -391,13 +399,13 @@ export class SalesInvoicesService {
const slip = await this.packingSlipsService.findById(input.packingSlipId); const slip = await this.packingSlipsService.findById(input.packingSlipId);
packingSlipCode = slip.code; packingSlipCode = slip.code;
date = date || DateTime.fromUnixMs(slip.date).format(); date = date || DateTime.fromUnixMs(slip.date).format();
customerId = customerId || slip.customerId; customerId = customerId || slip.customer?.id || '';
notes = notes !== undefined ? notes : slip.notes; notes = notes !== undefined ? notes : slip.notes;
products = products =
input.products ?? input.products ??
products ?? products ??
slip.products.map((line) => ({ slip.products.map((line) => ({
productId: line.productId, productId: line.product?.id ?? '',
quantity: line.quantity, quantity: line.quantity,
price: line.price, price: line.price,
})); }));
@@ -15,7 +15,12 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response'; import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { import {
DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_ADDRESS_MAX_LENGTH,
DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH,
@@ -272,18 +277,18 @@ export class SalesOrderDto {
id!: string; id!: string;
@ApiProperty() @ApiProperty()
code!: string; code!: string;
@ApiPropertyOptional({ nullable: true, format: 'uuid' }) @ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
salesRequestId!: string | null; salesRequest!: CodeRelationDto | null;
@ApiProperty() @ApiProperty()
date!: number; date!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
salesPersonId!: string; salesPerson!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
branchId!: string; branch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
divisionId!: string; division!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiProperty() @ApiProperty()
address!: string; address!: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
@@ -298,8 +303,8 @@ export class SalesOrderDto {
createdAt!: number; createdAt!: number;
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,8 @@
import type {
CodeRelation,
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type SalesOrderLine = { export type SalesOrderLine = {
readonly id: string; readonly id: string;
readonly productId: string; readonly productId: string;
readonly product: DefaultRelation | null;
readonly quantity: Decimal; readonly quantity: Decimal;
readonly price: Decimal; readonly price: Decimal;
}; };
@@ -35,6 +41,13 @@ export type SalesOrder = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: 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 = { export type SalesOrderLineInput = {
@@ -90,6 +103,8 @@ export type ListSalesOrdersFilters = {
readonly branchId?: string; readonly branchId?: string;
readonly divisionId?: string; readonly divisionId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -4,7 +4,15 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -29,6 +37,15 @@ import type {
UpdateSalesOrderInput, UpdateSalesOrderInput,
} from './sales-order'; } 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<DrizzleDB, 'select' | 'insert' | 'delete'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable() @Injectable()
@@ -50,11 +67,15 @@ export class SalesOrdersRepository {
.select() .select()
.from(salesOrders) .from(salesOrders)
.where(where) .where(where)
.orderBy(asc(salesOrders.code)) .orderBy(
...toOrderClauses(SALES_ORDER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [], [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -71,7 +92,7 @@ export class SalesOrdersRepository {
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
const images = await this.selectImages(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<SalesOrder> { async create(input: CreateSalesOrderInput): Promise<SalesOrder> {
@@ -95,7 +116,7 @@ export class SalesOrdersRepository {
await this.replaceImages(tx, row.id, input.images ?? []); await this.replaceImages(tx, row.id, input.images ?? []);
const products = await this.selectProducts(tx, row.id); const products = await this.selectProducts(tx, row.id);
const images = await this.selectImages(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) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -154,7 +175,7 @@ export class SalesOrdersRepository {
} }
const products = await this.selectProducts(tx, id); const products = await this.selectProducts(tx, id);
const images = await this.selectImages(tx, id); const images = await this.selectImages(tx, id);
return this.toDomain(row, products, images); return this.hydrateOne(row, products, images);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -182,7 +203,7 @@ export class SalesOrdersRepository {
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
const images = await this.selectImages(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( async bulkUpdateStatus(
@@ -372,6 +393,7 @@ export class SalesOrdersRepository {
products: productRows.map((line) => ({ products: productRows.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, productId: line.productId,
product: null,
quantity: Decimal.create(line.quantity), quantity: Decimal.create(line.quantity),
price: Decimal.create(line.price), price: Decimal.create(line.price),
})), })),
@@ -385,9 +407,47 @@ export class SalesOrdersRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, 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<SalesOrder[]> {
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<SalesOrder> {
const [item] = await this.hydrate([this.toDomain(row, products, images)]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
@@ -65,6 +65,7 @@ describe('SalesOrdersService', () => {
{ {
id: 'line-1', id: 'line-1',
productId: 'prd-1', productId: 'prd-1',
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
quantity: Decimal.create('2'), quantity: Decimal.create('2'),
price: Decimal.create('12500'), price: Decimal.create('12500'),
}, },
@@ -75,6 +76,13 @@ describe('SalesOrdersService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 = { const createBody = {
@@ -4,7 +4,13 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
@@ -55,6 +61,8 @@ export type ListSalesOrdersQuery = {
readonly branchId?: string; readonly branchId?: string;
readonly divisionId?: string; readonly divisionId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -93,6 +101,8 @@ export class SalesOrdersService {
branchId: query.branchId, branchId: query.branchId,
divisionId: query.divisionId, divisionId: query.divisionId,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -303,12 +313,12 @@ export class SalesOrdersService {
return { return {
id: item.id, id: item.id,
code: item.code, code: item.code,
salesRequestId: item.salesRequestId, salesRequest: pickCodeRelation(item.salesRequest),
date: item.date.value, date: item.date.value,
salesPersonId: item.salesPersonId, salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
branchId: item.branchId, branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
divisionId: item.divisionId, division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
customerId: item.customerId, customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
address: item.address, address: item.address,
latitude: item.latitude, latitude: item.latitude,
longitude: item.longitude, longitude: item.longitude,
@@ -316,8 +326,8 @@ export class SalesOrdersService {
status: item.status.value, status: item.status.value,
createdAt: item.createdAt.value, createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value, updatedAt: item.updatedAt.value,
createdBy: item.createdBy, createdBy: pickUserRelation(item.createdByUser),
updatedBy: item.updatedBy, updatedBy: pickUserRelation(item.updatedByUser),
}; };
} }
@@ -326,7 +336,7 @@ export class SalesOrdersService {
...this.toListItem(item), ...this.toListItem(item),
products: item.products.map((line) => ({ products: item.products.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
quantity: line.quantity.value, quantity: line.quantity.value,
price: line.price.value, price: line.price.value,
})), })),
@@ -373,10 +383,10 @@ export class SalesOrdersService {
return { return {
...input, ...input,
date: input.date ?? DateTime.fromUnixMs(source.date).format(), date: input.date ?? DateTime.fromUnixMs(source.date).format(),
salesPersonId: input.salesPersonId ?? source.salesPersonId, salesPersonId: input.salesPersonId ?? source.salesPerson?.id ?? '',
branchId: input.branchId ?? source.branchId, branchId: input.branchId ?? source.branch?.id ?? '',
divisionId: input.divisionId ?? source.divisionId, divisionId: input.divisionId ?? source.division?.id ?? '',
customerId: input.customerId ?? source.customerId, customerId: input.customerId ?? source.customer?.id ?? '',
address: input.address ?? source.address, address: input.address ?? source.address,
latitude: input.latitude !== undefined ? input.latitude : source.latitude, latitude: input.latitude !== undefined ? input.latitude : source.latitude,
longitude: longitude:
@@ -385,7 +395,7 @@ export class SalesOrdersService {
products: products:
input.products ?? input.products ??
source.products.map((line) => ({ source.products.map((line) => ({
productId: line.productId, productId: line.product?.id ?? '',
quantity: line.quantity, quantity: line.quantity,
price: line.price, price: line.price,
})), })),
@@ -12,7 +12,10 @@ import {
MaxLength, MaxLength,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response'; import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { import {
DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH,
DOCUMENT_CODE_PATTERN, DOCUMENT_CODE_PATTERN,
@@ -177,8 +180,8 @@ export class SalesPaymentDto {
createdAt!: number; createdAt!: number;
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,4 @@
import type { CodeRelation, UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -5,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type SalesPaymentAllocation = { export type SalesPaymentAllocation = {
readonly id: string; readonly id: string;
readonly invoiceId: string; readonly invoiceId: string;
readonly invoice: CodeRelation | null;
readonly amount: Decimal; readonly amount: Decimal;
}; };
@@ -26,6 +28,8 @@ export type SalesPayment = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
}; };
export type SalesPaymentAllocationInput = { export type SalesPaymentAllocationInput = {
@@ -61,6 +65,8 @@ export type ListSalesPaymentsFilters = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -4,7 +4,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -29,6 +35,15 @@ import type {
UpdateSalesPaymentInput, UpdateSalesPaymentInput,
} from './sales-payment'; } 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<DrizzleDB, 'select' | 'insert' | 'delete'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable() @Injectable()
@@ -50,11 +65,15 @@ export class SalesPaymentsRepository {
.select() .select()
.from(salesPayments) .from(salesPayments)
.where(where) .where(where)
.orderBy(asc(salesPayments.code)) .orderBy(
...toOrderClauses(SALES_PAYMENT_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [], [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -71,7 +90,7 @@ export class SalesPaymentsRepository {
} }
const invoices = await this.selectAllocations(this.db, id); const invoices = await this.selectAllocations(this.db, id);
const images = await this.selectImages(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<SalesPayment> { async create(input: CreateSalesPaymentInput): Promise<SalesPayment> {
@@ -96,7 +115,7 @@ export class SalesPaymentsRepository {
await this.replaceImages(tx, row.id, input.images ?? []); await this.replaceImages(tx, row.id, input.images ?? []);
const invoices = await this.selectAllocations(tx, row.id); const invoices = await this.selectAllocations(tx, row.id);
const images = await this.selectImages(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) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -147,7 +166,7 @@ export class SalesPaymentsRepository {
} }
const invoices = await this.selectAllocations(tx, id); const invoices = await this.selectAllocations(tx, id);
const images = await this.selectImages(tx, id); const images = await this.selectImages(tx, id);
return this.toDomain(row, invoices, images); return this.hydrateOne(row, invoices, images);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -175,7 +194,7 @@ export class SalesPaymentsRepository {
} }
const invoices = await this.selectAllocations(this.db, id); const invoices = await this.selectAllocations(this.db, id);
const images = await this.selectImages(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( async bulkUpdateStatus(
@@ -335,6 +354,7 @@ export class SalesPaymentsRepository {
invoices: allocationRows.map((line) => ({ invoices: allocationRows.map((line) => ({
id: line.id, id: line.id,
invoiceId: line.salesInvoiceId, invoiceId: line.salesInvoiceId,
invoice: null,
amount: Decimal.create(line.amount), amount: Decimal.create(line.amount),
})), })),
images: imageRows.map((image) => ({ images: imageRows.map((image) => ({
@@ -347,9 +367,35 @@ export class SalesPaymentsRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, updatedBy: row.updatedBy,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
}; };
} }
private async hydrate(items: SalesPayment[]): Promise<SalesPayment[]> {
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<SalesPayment> {
const [item] = await this.hydrate([this.toDomain(row, invoices, images)]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
@@ -51,6 +51,7 @@ describe('SalesPaymentsService', () => {
{ {
id: 'alloc-1', id: 'alloc-1',
invoiceId: 'si-1', invoiceId: 'si-1',
invoice: { id: 'si-1', code: 'SI-1' },
amount: Decimal.create('10000'), amount: Decimal.create('10000'),
}, },
], ],
@@ -60,6 +61,8 @@ describe('SalesPaymentsService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -4,7 +4,11 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
@@ -42,6 +46,8 @@ export type ListSalesPaymentsQuery = {
readonly code?: string; readonly code?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -66,6 +72,8 @@ export class SalesPaymentsService {
code: query.code, code: query.code,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -253,8 +261,8 @@ export class SalesPaymentsService {
status: item.status.value, status: item.status.value,
createdAt: item.createdAt.value, createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value, updatedAt: item.updatedAt.value,
createdBy: item.createdBy, createdBy: pickUserRelation(item.createdByUser),
updatedBy: item.updatedBy, updatedBy: pickUserRelation(item.updatedByUser),
}; };
} }
@@ -263,7 +271,7 @@ export class SalesPaymentsService {
...this.toListItem(item), ...this.toListItem(item),
invoices: item.invoices.map((line) => ({ invoices: item.invoices.map((line) => ({
id: line.id, id: line.id,
invoiceId: line.invoiceId, invoice: pickCodeRelation(line.invoice),
amount: line.amount.value, amount: line.amount.value,
})), })),
images: item.images.map((image) => ({ images: item.images.map((image) => ({
@@ -15,7 +15,11 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response'; import {
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { import {
DOCUMENT_ADDRESS_MAX_LENGTH, DOCUMENT_ADDRESS_MAX_LENGTH,
DOCUMENT_CODE_MAX_LENGTH, DOCUMENT_CODE_MAX_LENGTH,
@@ -269,14 +273,14 @@ export class SalesRequestDto {
code!: string; code!: string;
@ApiProperty() @ApiProperty()
date!: number; date!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
salesPersonId!: string; salesPerson!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
branchId!: string; branch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
divisionId!: string; division!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: DefaultRelationDto, nullable: true })
customerId!: string; customer!: DefaultRelationDto | null;
@ApiProperty() @ApiProperty()
address!: string; address!: string;
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
@@ -291,8 +295,8 @@ export class SalesRequestDto {
createdAt!: number; createdAt!: number;
@ApiProperty() @ApiProperty()
updatedAt!: number; updatedAt!: number;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
createdBy!: string; createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' }) @ApiProperty({ type: UserRelationDto })
updatedBy!: string; updatedBy!: UserRelationDto;
} }
@@ -1,3 +1,7 @@
import type {
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -5,6 +9,7 @@ import { Status } from '../../../common/value-objects/status/status';
export type SalesRequestLine = { export type SalesRequestLine = {
readonly id: string; readonly id: string;
readonly productId: string; readonly productId: string;
readonly product: DefaultRelation | null;
readonly quantity: Decimal; readonly quantity: Decimal;
readonly price: Decimal; readonly price: Decimal;
}; };
@@ -34,6 +39,12 @@ export type SalesRequest = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: 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 = { export type SalesRequestLineInput = {
@@ -88,6 +99,8 @@ export type ListSalesRequestsFilters = {
readonly branchId?: string; readonly branchId?: string;
readonly divisionId?: string; readonly divisionId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
@@ -4,7 +4,13 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -29,6 +35,15 @@ import type {
UpdateSalesRequestInput, UpdateSalesRequestInput,
} from './sales-request'; } 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<DrizzleDB, 'select' | 'insert' | 'delete'>; type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable() @Injectable()
@@ -50,11 +65,15 @@ export class SalesRequestsRepository {
.select() .select()
.from(salesRequests) .from(salesRequests)
.where(where) .where(where)
.orderBy(asc(salesRequests.code)) .orderBy(
...toOrderClauses(SALES_REQUEST_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
return { return {
data: rows.map((row) => this.toDomain(row, [], [])), data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
total: Number(totalRows[0]?.total ?? 0), total: Number(totalRows[0]?.total ?? 0),
}; };
} }
@@ -71,7 +90,7 @@ export class SalesRequestsRepository {
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
const images = await this.selectImages(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<SalesRequest> { async create(input: CreateSalesRequestInput): Promise<SalesRequest> {
@@ -96,7 +115,7 @@ export class SalesRequestsRepository {
await this.replaceImages(tx, row.id, input.images ?? []); await this.replaceImages(tx, row.id, input.images ?? []);
const products = await this.selectProducts(tx, row.id); const products = await this.selectProducts(tx, row.id);
const images = await this.selectImages(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) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -158,7 +177,7 @@ export class SalesRequestsRepository {
} }
const products = await this.selectProducts(tx, id); const products = await this.selectProducts(tx, id);
const images = await this.selectImages(tx, id); const images = await this.selectImages(tx, id);
return this.toDomain(row, products, images); return this.hydrateOne(row, products, images);
}); });
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
@@ -186,7 +205,7 @@ export class SalesRequestsRepository {
} }
const products = await this.selectProducts(this.db, id); const products = await this.selectProducts(this.db, id);
const images = await this.selectImages(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( async bulkUpdateStatus(
@@ -374,6 +393,7 @@ export class SalesRequestsRepository {
products: productRows.map((line) => ({ products: productRows.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, productId: line.productId,
product: null,
quantity: Decimal.create(line.quantity), quantity: Decimal.create(line.quantity),
price: Decimal.create(line.price), price: Decimal.create(line.price),
})), })),
@@ -387,9 +407,39 @@ export class SalesRequestsRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy, createdBy: row.createdBy,
updatedBy: row.updatedBy, 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<SalesRequest[]> {
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<SalesRequest> {
const [item] = await this.hydrate([this.toDomain(row, products, images)]);
return item;
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
throw error; throw error;
@@ -62,6 +62,7 @@ describe('SalesRequestsService', () => {
{ {
id: 'line-1', id: 'line-1',
productId: 'prd-1', productId: 'prd-1',
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
quantity: Decimal.create('2'), quantity: Decimal.create('2'),
price: Decimal.create('12500'), price: Decimal.create('12500'),
}, },
@@ -72,6 +73,12 @@ describe('SalesRequestsService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: '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 = { const createBody = {
@@ -4,7 +4,12 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response'; 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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Decimal } from '../../../common/value-objects/decimal/decimal'; import { Decimal } from '../../../common/value-objects/decimal/decimal';
@@ -54,6 +59,8 @@ export type ListSalesRequestsQuery = {
readonly branchId?: string; readonly branchId?: string;
readonly divisionId?: string; readonly divisionId?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -93,6 +100,8 @@ export class SalesRequestsService {
branchId: query.branchId, branchId: query.branchId,
divisionId: query.divisionId, divisionId: query.divisionId,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
@@ -302,10 +311,10 @@ export class SalesRequestsService {
id: item.id, id: item.id,
code: item.code, code: item.code,
date: item.date.value, date: item.date.value,
salesPersonId: item.salesPersonId, salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
branchId: item.branchId, branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
divisionId: item.divisionId, division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
customerId: item.customerId, customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
address: item.address, address: item.address,
latitude: item.latitude, latitude: item.latitude,
longitude: item.longitude, longitude: item.longitude,
@@ -313,8 +322,8 @@ export class SalesRequestsService {
status: item.status.value, status: item.status.value,
createdAt: item.createdAt.value, createdAt: item.createdAt.value,
updatedAt: item.updatedAt.value, updatedAt: item.updatedAt.value,
createdBy: item.createdBy, createdBy: pickUserRelation(item.createdByUser),
updatedBy: item.updatedBy, updatedBy: pickUserRelation(item.updatedByUser),
}; };
} }
@@ -323,7 +332,7 @@ export class SalesRequestsService {
...this.toListItem(item), ...this.toListItem(item),
products: item.products.map((line) => ({ products: item.products.map((line) => ({
id: line.id, id: line.id,
productId: line.productId, product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
quantity: line.quantity.value, quantity: line.quantity.value,
price: line.price.value, price: line.price.value,
})), })),
@@ -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<Array<T & SalesHeaderRelations>> {
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,
};
+2
View File
@@ -53,6 +53,8 @@ export type ListUsersFilters = {
readonly privilegeId?: string; readonly privilegeId?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number; readonly limit: number;
readonly offset: number; readonly offset: number;
}; };
+15 -2
View File
@@ -4,7 +4,8 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } 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 { alias } from 'drizzle-orm/pg-core';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time'; import { DateTime } from '../../common/value-objects/date-time/date-time';
@@ -23,6 +24,14 @@ import type {
User, User,
} from './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 createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users'); const updatedByUsers = alias(users, 'updated_by_users');
@@ -57,7 +66,11 @@ export class UsersRepository {
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
.orderBy(asc(users.username)) .orderBy(
...toOrderClauses(USER_ORDER_COLUMNS, filters, [
{ column: 'username', type: 'ASC' },
]),
)
.limit(filters.limit) .limit(filters.limit)
.offset(filters.offset); .offset(filters.offset);
+2
View File
@@ -69,6 +69,8 @@ describe('UsersService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'actor-1', createdBy: 'actor-1',
updatedBy: 'actor-1', updatedBy: 'actor-1',
createdByUser: { id: 'actor-1', username: 'actor' },
updatedByUser: { id: 'actor-1', username: 'actor' },
userId: null, userId: null,
user: null, user: null,
}; };
+4
View File
@@ -31,6 +31,8 @@ export type ListUsersQuery = {
readonly privilegeId?: string; readonly privilegeId?: string;
readonly status?: string; readonly status?: string;
readonly search?: string; readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number; readonly page?: number;
readonly limit?: number; readonly limit?: number;
readonly offset?: number; readonly offset?: number;
@@ -69,6 +71,8 @@ export class UsersService {
privilegeId: query.privilegeId, privilegeId: query.privilegeId,
status: query.status, status: query.status,
search: query.search, search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
+1 -4
View File
@@ -19,10 +19,7 @@ export async function registerAndActivate(
); );
} }
const userId = (register.body as { id: string }).id; const userId = (register.body as { id: string }).id;
await db await db.update(users).set({ status: 'active' }).where(eq(users.id, userId));
.update(users)
.set({ status: 'active' })
.where(eq(users.id, userId));
const login = await request(app.getHttpServer()) const login = await request(app.getHttpServer())
.post('/auth/login') .post('/auth/login')
.send({ username, password }); .send({ username, password });