Enhance branch management with foreign key relation handling

- Updated `BranchesModule` to include foreign key relations in list and write responses, ensuring they are represented as nested objects using `pickRelation`.
- Introduced new `relation-response.mdc` file to define guidelines for embedding foreign key relations.
- Modified `BranchesRepository` to support fetching related `division`, `createdByUser`, and `updatedByUser` data.
- Updated DTOs and service methods to reflect changes in response structure, removing direct foreign key IDs.
- Added unit tests to validate the new relation handling in branches service and repository.
- Enhanced e2e tests to verify the correct structure of branch responses with nested relations.
This commit is contained in:
shancheas
2026-08-26 13:44:13 +07:00
parent c9f9b31abf
commit f635ebeda0
13 changed files with 392 additions and 68 deletions
+1
View File
@@ -38,6 +38,7 @@ List requirements:
- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto` - Shared pagination query (`page`/`limit` or `offset`/`limit`) 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`
- List query must be extendable (e.g. `extendListQuery(qb, filters)` on the repository/service) so joins/extra predicates can be added without forking list - List query must be extendable (e.g. `extendListQuery(qb, filters)` on the repository/service) so joins/extra predicates can be added without forking list
## Write controller ## Write controller
+33
View File
@@ -0,0 +1,33 @@
---
description: List/detail (and write responses that reuse the mapper) embed FK relations as objects via pickRelation
globs: "src/modules/**/*.ts,src/common/http/response/**/*.ts"
alwaysApply: false
---
# Relation Response Objects
List, detail, and write handlers that reuse the same mapper MUST embed foreign keys as nested objects, not bare ids.
## Field lists
- Default catalog fields: `DEFAULT_RELATION_FIELDS` (`id`, `code`, `name`) from `src/common/http/response/`
- Override per entity with a module/local constant (users: `USER_RELATION_FIELDS` = `id`, `username`)
- Use `pickRelation(source, fields)` only — do not hand-roll partial copies
## Mapping
- Request DTOs still accept `*Id` (`divisionId`); the response key is the relation name (`division`, not `divisionId`)
- Null FK → `null` (not omitted)
- Never expose secrets (`passwordHash`, tokens) in relation objects
- Load relations in the repository (joins or batch-load); map with `pickRelation` in the service
```typescript
// BAD
return { divisionId: branch.divisionId, createdBy: branch.createdBy }
// GOOD
return {
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
createdBy: pickRelation(branch.createdByUser, USER_RELATION_FIELDS),
}
```
+10
View File
@@ -22,3 +22,13 @@ export {
PAGINATION_DEFAULT_LIMIT, PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT, PAGINATION_MAX_LIMIT,
} from './pagination.constants'; } from './pagination.constants';
export {
DEFAULT_RELATION_FIELDS,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
type DefaultRelation,
type UserRelation,
} from './relation-fields';
export { DefaultRelationDto, UserRelationDto } from './relation.dto';
@@ -0,0 +1,78 @@
import {
DEFAULT_RELATION_FIELDS,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
} from './relation-fields';
describe('pickRelation', () => {
const catalog = {
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
status: 'active',
extra: 'secret',
};
const user = {
id: 'user-1',
username: 'admin',
passwordHash: 'hashed',
};
it('picks default id, code, name fields', () => {
expect(pickRelation(catalog, DEFAULT_RELATION_FIELDS)).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
});
it('picks a custom field list for users', () => {
expect(pickRelation(user, USER_RELATION_FIELDS)).toEqual({
id: 'user-1',
username: 'admin',
});
});
it('returns null for null or undefined sources', () => {
const missing: typeof catalog | null = null;
const unset: typeof catalog | undefined = undefined;
expect(
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
missing,
DEFAULT_RELATION_FIELDS,
),
).toBeNull();
expect(
pickRelation<typeof catalog, (typeof DEFAULT_RELATION_FIELDS)[number]>(
unset,
DEFAULT_RELATION_FIELDS,
),
).toBeNull();
});
it('does not copy fields outside the list', () => {
const picked = pickRelation(catalog, DEFAULT_RELATION_FIELDS);
expect(picked).not.toHaveProperty('status');
expect(picked).not.toHaveProperty('extra');
});
});
describe('pickDefaultRelation / pickUserRelation', () => {
it('maps catalog and user sources without leaking extra fields', () => {
expect(
pickDefaultRelation({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
}),
).toEqual({ id: 'div-1', code: 'JKT', name: 'Jakarta' });
expect(pickDefaultRelation(null)).toBeNull();
expect(pickUserRelation({ id: 'user-1', username: 'admin' })).toEqual({
id: 'user-1',
username: 'admin',
});
});
});
@@ -0,0 +1,42 @@
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
export type DefaultRelation = {
readonly id: string;
readonly code: string;
readonly name: string;
};
export type UserRelation = {
readonly id: string;
readonly username: string;
};
export function pickRelation<T, K extends keyof NonNullable<T>>(
source: T | null | undefined,
fields: readonly K[],
): Pick<NonNullable<T>, K> | null {
if (source == null) {
return null;
}
const result = {} as Pick<NonNullable<T>, K>;
for (const field of fields) {
result[field] = source[field];
}
return result;
}
export function pickDefaultRelation(
source: DefaultRelation | null | undefined,
): DefaultRelation | null {
return pickRelation(source, DEFAULT_RELATION_FIELDS);
}
export function pickUserRelation(source: UserRelation): UserRelation {
return (
pickRelation(source, USER_RELATION_FIELDS) ?? {
id: source.id,
username: source.username,
}
);
}
+20
View File
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class DefaultRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
@ApiProperty()
name!: string;
}
export class UserRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
username!: string;
}
@@ -21,6 +21,19 @@ export type Branch = {
readonly updatedAt: DateTime; readonly updatedAt: DateTime;
readonly createdBy: string; readonly createdBy: string;
readonly updatedBy: string; readonly updatedBy: string;
readonly division: {
readonly id: string;
readonly code: string;
readonly name: string;
} | null;
readonly createdByUser: {
readonly id: string;
readonly username: string;
};
readonly updatedByUser: {
readonly id: string;
readonly username: string;
};
}; };
export type CreateBranchInput = { export type CreateBranchInput = {
@@ -26,6 +26,7 @@ describe('BranchesRepository', () => {
const del = jest.fn(); const del = jest.fn();
const transaction = jest.fn(); const transaction = jest.fn();
const $dynamic = jest.fn(); const $dynamic = jest.fn();
const leftJoin = jest.fn();
const db = { const db = {
select, select,
@@ -56,6 +57,13 @@ describe('BranchesRepository', () => {
updatedBy: 'user-1', updatedBy: 'user-1',
}; };
const joinedRow = {
branch: row,
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
const createInput = { const createInput = {
code: 'JKT_01', code: 'JKT_01',
name: 'Jakarta Pusat', name: 'Jakarta Pusat',
@@ -68,16 +76,34 @@ describe('BranchesRepository', () => {
userId: 'user-1', userId: 'user-1',
}; };
beforeEach(async () => { const joinChain = () => {
jest.clearAllMocks(); const chain: {
where.mockImplementation(() => ({ limit, orderBy })); leftJoin: jest.Mock;
orderBy.mockImplementation(() => ({ limit })); where: typeof where;
limit.mockImplementation(() => ({ offset })); $dynamic: typeof $dynamic;
offset.mockResolvedValue([row]); } = {
from.mockImplementation(() => ({ leftJoin: jest.fn(),
where, where,
$dynamic, $dynamic,
};
chain.leftJoin.mockReturnValue(chain);
return chain;
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({
offset,
then: (
resolve: (value: (typeof joinedRow)[]) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve([joinedRow]).then(resolve, reject),
})); }));
offset.mockResolvedValue([joinedRow]);
from.mockImplementation(() => joinChain());
leftJoin.mockImplementation(() => joinChain());
$dynamic.mockReturnValue({ where }); $dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from })); select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning }); values.mockReturnValue({ returning });
@@ -86,7 +112,6 @@ describe('BranchesRepository', () => {
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: [BranchesRepository, { provide: DRIZZLE, useValue: db }], providers: [BranchesRepository, { provide: DRIZZLE, useValue: db }],
@@ -95,13 +120,15 @@ describe('BranchesRepository', () => {
}); });
it('findById maps a row to domain Branch', async () => { it('findById maps a row to domain Branch', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findById('br-1'); const branch = await repository.findById('br-1');
expect(branch).toMatchObject({ expect(branch).toMatchObject({
id: 'br-1', id: 'br-1',
code: 'JKT_01', code: 'JKT_01',
name: 'Jakarta Pusat', name: 'Jakarta Pusat',
createdBy: 'user-1', createdBy: 'user-1',
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
}); });
expect(branch?.phone.value).toBe('+6281234567890'); expect(branch?.phone.value).toBe('+6281234567890');
expect(branch?.status.value).toBe('draft'); expect(branch?.status.value).toBe('draft');
@@ -109,12 +136,11 @@ describe('BranchesRepository', () => {
}); });
it('findById returns null when missing', async () => { it('findById returns null when missing', async () => {
limit.mockResolvedValueOnce([]); limit.mockImplementationOnce(() => Promise.resolve([]));
await expect(repository.findById('missing')).resolves.toBeNull(); await expect(repository.findById('missing')).resolves.toBeNull();
}); });
it('findByCode maps a row', async () => { it('findByCode maps a row', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findByCode('JKT_01'); const branch = await repository.findByCode('JKT_01');
expect(branch?.code).toBe('JKT_01'); expect(branch?.code).toBe('JKT_01');
}); });
@@ -127,17 +153,10 @@ describe('BranchesRepository', () => {
}), }),
})) }))
.mockImplementationOnce(() => ({ .mockImplementationOnce(() => ({
from: () => ({ from: () => {
$dynamic: () => ({ const chain = joinChain();
where: () => ({ return chain;
orderBy: () => ({ },
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
})); }));
const result = await repository.list({ const result = await repository.list({
@@ -158,12 +177,18 @@ describe('BranchesRepository', () => {
}); });
expect(result.total).toBe(1); expect(result.total).toBe(1);
expect(result.data[0].code).toBe('JKT_01'); expect(result.data[0].code).toBe('JKT_01');
expect(result.data[0].division).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
}); });
it('create inserts and maps unique violations', async () => { it('create inserts and maps unique violations', async () => {
returning.mockResolvedValueOnce([row]); returning.mockResolvedValueOnce([row]);
const created = await repository.create(createInput); const created = await repository.create(createInput);
expect(created.code).toBe('JKT_01'); expect(created.code).toBe('JKT_01');
expect(created.createdByUser).toEqual({ id: 'user-1', username: 'admin' });
returning.mockRejectedValueOnce({ code: '23505' }); returning.mockRejectedValueOnce({ code: '23505' });
await expect(repository.create(createInput)).rejects.toBeInstanceOf( await expect(repository.create(createInput)).rejects.toBeInstanceOf(
@@ -209,17 +234,17 @@ describe('BranchesRepository', () => {
'user-1', 'user-1',
); );
expect(updated.id).toBe('br-1'); expect(updated.id).toBe('br-1');
expect(updated.updatedByUser).toEqual({ id: 'user-1', username: 'admin' });
}); });
it('update throws when missing', async () => { it('update throws when missing', async () => {
limit.mockResolvedValueOnce([]); limit.mockImplementationOnce(() => Promise.resolve([]));
await expect( await expect(
repository.update('missing', { userId: 'user-1' }), repository.update('missing', { userId: 'user-1' }),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
}); });
it('update maps a row when present', async () => { it('update maps a row when present', async () => {
limit.mockResolvedValueOnce([row]);
returning.mockResolvedValueOnce([row]); returning.mockResolvedValueOnce([row]);
const updated = await repository.update('br-1', { const updated = await repository.update('br-1', {
name: 'Jakarta Selatan', name: 'Jakarta Selatan',
@@ -6,6 +6,7 @@ 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 { 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';
import { Status } from '../../../common/value-objects/status/status'; import { Status } from '../../../common/value-objects/status/status';
@@ -15,6 +16,7 @@ import {
type BranchRow, type BranchRow,
type NewBranchRow, type NewBranchRow,
} from '../../../database/branches-table'; } from '../../../database/branches-table';
import { divisions, users } from '../../../database/schema';
import type { import type {
Branch, Branch,
CreateBranchInput, CreateBranchInput,
@@ -22,6 +24,16 @@ import type {
UpdateBranchInput, UpdateBranchInput,
} from './branch'; } from './branch';
const createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users');
type BranchJoinedRow = {
branch: BranchRow;
division: typeof divisions.$inferSelect | null;
createdByUser: typeof users.$inferSelect | null;
updatedByUser: typeof users.$inferSelect | null;
};
@Injectable() @Injectable()
export class BranchesRepository { export class BranchesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {} constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -36,7 +48,7 @@ export class BranchesRepository {
.where(where); .where(where);
const totalRow = totalRows[0]; const totalRow = totalRows[0];
let qb = this.db.select().from(branches).$dynamic(); let qb = this.selectWithRelations().$dynamic();
qb = this.extendListQuery(qb, filters); qb = this.extendListQuery(qb, filters);
const rows = await qb const rows = await qb
.where(where) .where(where)
@@ -59,9 +71,7 @@ export class BranchesRepository {
} }
async findById(id: string): Promise<Branch | null> { async findById(id: string): Promise<Branch | null> {
const rows: BranchRow[] = await this.db const rows = await this.selectWithRelations()
.select()
.from(branches)
.where(eq(branches.id, id)) .where(eq(branches.id, id))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
@@ -69,9 +79,7 @@ export class BranchesRepository {
} }
async findByCode(code: string): Promise<Branch | null> { async findByCode(code: string): Promise<Branch | null> {
const rows = await this.db const rows = await this.selectWithRelations()
.select()
.from(branches)
.where(eq(branches.code, code)) .where(eq(branches.code, code))
.limit(1); .limit(1);
const row = rows[0]; const row = rows[0];
@@ -87,7 +95,7 @@ export class BranchesRepository {
.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 this.requireById(row.id);
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
} }
@@ -151,7 +159,7 @@ export class BranchesRepository {
if (!row) { if (!row) {
throw new NotFoundException('Branch not found'); throw new NotFoundException('Branch not found');
} }
return this.toDomain(row); return this.requireById(row.id);
} catch (error) { } catch (error) {
this.rethrowConstraintViolation(error); this.rethrowConstraintViolation(error);
} }
@@ -176,7 +184,7 @@ export class BranchesRepository {
if (!row) { if (!row) {
throw new NotFoundException('Branch not found'); throw new NotFoundException('Branch not found');
} }
return this.toDomain(row); return this.requireById(row.id);
} }
async bulkUpdateStatus( async bulkUpdateStatus(
@@ -221,6 +229,28 @@ export class BranchesRepository {
return deleted.length; return deleted.length;
} }
private selectWithRelations() {
return this.db
.select({
branch: branches,
division: divisions,
createdByUser: createdByUsers,
updatedByUser: updatedByUsers,
})
.from(branches)
.leftJoin(divisions, eq(branches.divisionId, divisions.id))
.leftJoin(createdByUsers, eq(branches.createdBy, createdByUsers.id))
.leftJoin(updatedByUsers, eq(branches.updatedBy, updatedByUsers.id));
}
private async requireById(id: string): Promise<Branch> {
const loaded = await this.findById(id);
if (!loaded) {
throw new NotFoundException('Branch not found');
}
return loaded;
}
private buildListWhere(filters: ListBranchesFilters): SQL | undefined { private buildListWhere(filters: ListBranchesFilters): SQL | undefined {
const parts: SQL[] = []; const parts: SQL[] = [];
if (filters.code) { if (filters.code) {
@@ -299,29 +329,52 @@ export class BranchesRepository {
}; };
} }
private toDomain(row: BranchRow): Branch { private toDomain(row: BranchJoinedRow): Branch {
const branch = row.branch;
return { return {
id: row.id, id: branch.id,
code: row.code, code: branch.code,
name: row.name, name: branch.name,
phone: PhoneNumber.create(row.phone), phone: PhoneNumber.create(branch.phone),
address: row.address, address: branch.address,
latitude: row.latitude, latitude: branch.latitude,
longitude: row.longitude, longitude: branch.longitude,
workingDaysStart: row.workingDaysStart, workingDaysStart: branch.workingDaysStart,
workingDaysEnd: row.workingDaysEnd, workingDaysEnd: branch.workingDaysEnd,
workingHoursStart: row.workingHoursStart, workingHoursStart: branch.workingHoursStart,
workingHoursEnd: row.workingHoursEnd, workingHoursEnd: branch.workingHoursEnd,
nfcId: row.nfcId, nfcId: branch.nfcId,
divisionId: row.divisionId, divisionId: branch.divisionId,
status: Status.create(row.status), status: Status.create(branch.status),
createdAt: DateTime.fromUnixMs(row.createdAt), createdAt: DateTime.fromUnixMs(branch.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt), updatedAt: DateTime.fromUnixMs(branch.updatedAt),
createdBy: row.createdBy, createdBy: branch.createdBy,
updatedBy: row.updatedBy, updatedBy: branch.updatedBy,
division: this.toDefaultRelation(row.division),
createdByUser: this.toUserRelation(row.createdByUser, branch.createdBy),
updatedByUser: this.toUserRelation(row.updatedByUser, branch.updatedBy),
}; };
} }
private toDefaultRelation(
row: { id: string; code: string; name: string } | null,
): Branch['division'] {
if (!row?.id) {
return null;
}
return { id: row.id, code: row.code, name: row.name };
}
private toUserRelation(
row: { id: string; username: string } | null,
fallbackId: string,
): Branch['createdByUser'] {
if (row?.id) {
return { id: row.id, username: row.username };
}
return { id: fallbackId, username: '' };
}
private rethrowConstraintViolation(error: unknown): never { private rethrowConstraintViolation(error: unknown): never {
const err = error as { code?: string; constraint?: string }; const err = error as { code?: string; constraint?: string };
if (err.code === '23505') { if (err.code === '23505') {
@@ -44,6 +44,9 @@ describe('BranchesService', () => {
updatedAt: now, updatedAt: now,
createdBy: 'user-1', createdBy: 'user-1',
updatedBy: 'user-1', updatedBy: 'user-1',
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
}; };
const createInput = { const createInput = {
@@ -92,8 +95,14 @@ describe('BranchesService', () => {
phone: '+6281234567890', phone: '+6281234567890',
status: 'draft', status: 'draft',
createdAt: now.value, createdAt: now.value,
division: { id: 'div-1', code: 'JKT', name: 'Jakarta' },
createdBy: { id: 'user-1', username: 'admin' },
updatedBy: { id: 'user-1', username: 'admin' },
}); });
expect(result.data[0]).not.toHaveProperty('divisionId');
expect(service.visibleFields).toContain('phone'); expect(service.visibleFields).toContain('phone');
expect(service.visibleFields).toContain('division');
expect(service.visibleFields).not.toContain('divisionId');
}); });
it('findById throws when missing', async () => { it('findById throws when missing', async () => {
@@ -108,6 +117,12 @@ describe('BranchesService', () => {
const result = await service.findById('br-1'); const result = await service.findById('br-1');
expect(result.id).toBe('br-1'); expect(result.id).toBe('br-1');
expect(result.phone).toBe('+6281234567890'); expect(result.phone).toBe('+6281234567890');
expect(result.division).toEqual({
id: 'div-1',
code: 'JKT',
name: 'Jakarta',
});
expect(result.createdBy).toEqual({ id: 'user-1', username: 'admin' });
}); });
it('create defaults status to draft and stores E.164 phone', async () => { it('create defaults status to draft and stores E.164 phone', async () => {
@@ -54,7 +54,7 @@ const VISIBLE_FIELDS = [
'workingHoursStart', 'workingHoursStart',
'workingHoursEnd', 'workingHoursEnd',
'nfcId', 'nfcId',
'divisionId', 'division',
'status', 'status',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
@@ -341,15 +341,31 @@ export class BranchesService {
workingHoursStart: branch.workingHoursStart, workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd, workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId, nfcId: branch.nfcId,
divisionId: branch.divisionId, division: this.toDivisionItem(branch.division),
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: branch.createdBy, createdBy: this.toUserItem(branch.createdByUser),
updatedBy: branch.updatedBy, updatedBy: this.toUserItem(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;
} }
@@ -14,7 +14,11 @@ import {
MaxLength, MaxLength,
Min, Min,
} 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 { import {
BRANCH_ADDRESS_MAX_LENGTH, BRANCH_ADDRESS_MAX_LENGTH,
@@ -320,8 +324,8 @@ export class BranchDto {
@ApiPropertyOptional({ nullable: true }) @ApiPropertyOptional({ nullable: true })
nfcId!: string | null; nfcId!: string | null;
@ApiPropertyOptional({ format: 'uuid', nullable: true }) @ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
divisionId!: string | null; division!: DefaultRelationDto | null;
@ApiProperty({ enum: CORE_STATUSES }) @ApiProperty({ enum: CORE_STATUSES })
status!: string; status!: string;
@@ -332,9 +336,9 @@ export class BranchDto {
@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;
} }
+18 -4
View File
@@ -26,6 +26,8 @@ describe('Branches (e2e)', () => {
let adminUserId: string; let adminUserId: string;
let otherAccessToken: string; let otherAccessToken: string;
let divisionId: string; let divisionId: string;
let divisionCode: string;
const divisionName = 'Jakarta Division';
const payload = { const payload = {
code: `JKT_${Date.now().toString().slice(-6)}`, code: `JKT_${Date.now().toString().slice(-6)}`,
@@ -103,11 +105,12 @@ describe('Branches (e2e)', () => {
.post('/divisions') .post('/divisions')
.set('Authorization', `Bearer ${adminAccessToken}`) .set('Authorization', `Bearer ${adminAccessToken}`)
.send({ .send({
name: 'Jakarta Division', name: divisionName,
code: `JD_${Date.now().toString().slice(-6)}`, code: `JD_${Date.now().toString().slice(-6)}`,
}) })
.expect(201); .expect(201);
divisionId = (division.body as { id: string }).id; divisionId = (division.body as { id: string }).id;
divisionCode = (division.body as { code: string }).code;
}); });
afterAll(async () => { afterAll(async () => {
@@ -137,11 +140,13 @@ describe('Branches (e2e)', () => {
name: 'Jakarta Pusat', name: 'Jakarta Pusat',
phone: '+6281234567890', phone: '+6281234567890',
status: 'draft', status: 'draft',
createdBy: adminUserId, createdBy: { id: adminUserId, username: adminUsername },
updatedBy: { id: adminUserId, username: adminUsername },
latitude: null, latitude: null,
nfcId: null, nfcId: null,
divisionId: null, division: null,
}); });
expect(created.body).not.toHaveProperty('divisionId');
const id = (created.body as { id: string }).id; const id = (created.body as { id: string }).id;
await request(app.getHttpServer()) await request(app.getHttpServer())
@@ -167,7 +172,7 @@ describe('Branches (e2e)', () => {
.set('Authorization', `Bearer ${adminAccessToken}`) .set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200); .expect(200);
await request(app.getHttpServer()) const patched = await request(app.getHttpServer())
.patch(`/branches/${id}`) .patch(`/branches/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`) .set('Authorization', `Bearer ${adminAccessToken}`)
.send({ .send({
@@ -178,6 +183,15 @@ describe('Branches (e2e)', () => {
divisionId, divisionId,
}) })
.expect(200); .expect(200);
expect(patched.body).toMatchObject({
name: 'Jakarta Selatan',
division: {
id: divisionId,
code: divisionCode,
name: divisionName,
},
createdBy: { id: adminUserId, username: adminUsername },
});
await request(app.getHttpServer()) await request(app.getHttpServer())
.patch(`/branches/${id}`) .patch(`/branches/${id}`)