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
+10
View File
@@ -22,3 +22,13 @@ export {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} 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 createdBy: 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 = {
@@ -26,6 +26,7 @@ describe('BranchesRepository', () => {
const del = jest.fn();
const transaction = jest.fn();
const $dynamic = jest.fn();
const leftJoin = jest.fn();
const db = {
select,
@@ -56,6 +57,13 @@ describe('BranchesRepository', () => {
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 = {
code: 'JKT_01',
name: 'Jakarta Pusat',
@@ -68,16 +76,34 @@ describe('BranchesRepository', () => {
userId: 'user-1',
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
from.mockImplementation(() => ({
const joinChain = () => {
const chain: {
leftJoin: jest.Mock;
where: typeof where;
$dynamic: typeof $dynamic;
} = {
leftJoin: jest.fn(),
where,
$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 });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
@@ -86,7 +112,6 @@ describe('BranchesRepository', () => {
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([row]);
where.mockImplementation(() => ({ limit, orderBy, returning }));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [BranchesRepository, { provide: DRIZZLE, useValue: db }],
@@ -95,13 +120,15 @@ describe('BranchesRepository', () => {
});
it('findById maps a row to domain Branch', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findById('br-1');
expect(branch).toMatchObject({
id: 'br-1',
code: 'JKT_01',
name: 'Jakarta Pusat',
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?.status.value).toBe('draft');
@@ -109,12 +136,11 @@ describe('BranchesRepository', () => {
});
it('findById returns null when missing', async () => {
limit.mockResolvedValueOnce([]);
limit.mockImplementationOnce(() => Promise.resolve([]));
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('findByCode maps a row', async () => {
limit.mockResolvedValueOnce([row]);
const branch = await repository.findByCode('JKT_01');
expect(branch?.code).toBe('JKT_01');
});
@@ -127,17 +153,10 @@ describe('BranchesRepository', () => {
}),
}))
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
from: () => {
const chain = joinChain();
return chain;
},
}));
const result = await repository.list({
@@ -158,12 +177,18 @@ describe('BranchesRepository', () => {
});
expect(result.total).toBe(1);
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 () => {
returning.mockResolvedValueOnce([row]);
const created = await repository.create(createInput);
expect(created.code).toBe('JKT_01');
expect(created.createdByUser).toEqual({ id: 'user-1', username: 'admin' });
returning.mockRejectedValueOnce({ code: '23505' });
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
@@ -209,17 +234,17 @@ describe('BranchesRepository', () => {
'user-1',
);
expect(updated.id).toBe('br-1');
expect(updated.updatedByUser).toEqual({ id: 'user-1', username: 'admin' });
});
it('update throws when missing', async () => {
limit.mockResolvedValueOnce([]);
limit.mockImplementationOnce(() => Promise.resolve([]));
await expect(
repository.update('missing', { userId: 'user-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('update maps a row when present', async () => {
limit.mockResolvedValueOnce([row]);
returning.mockResolvedValueOnce([row]);
const updated = await repository.update('br-1', {
name: 'Jakarta Selatan',
@@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
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 { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -15,6 +16,7 @@ import {
type BranchRow,
type NewBranchRow,
} from '../../../database/branches-table';
import { divisions, users } from '../../../database/schema';
import type {
Branch,
CreateBranchInput,
@@ -22,6 +24,16 @@ import type {
UpdateBranchInput,
} 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()
export class BranchesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -36,7 +48,7 @@ export class BranchesRepository {
.where(where);
const totalRow = totalRows[0];
let qb = this.db.select().from(branches).$dynamic();
let qb = this.selectWithRelations().$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
@@ -59,9 +71,7 @@ export class BranchesRepository {
}
async findById(id: string): Promise<Branch | null> {
const rows: BranchRow[] = await this.db
.select()
.from(branches)
const rows = await this.selectWithRelations()
.where(eq(branches.id, id))
.limit(1);
const row = rows[0];
@@ -69,9 +79,7 @@ export class BranchesRepository {
}
async findByCode(code: string): Promise<Branch | null> {
const rows = await this.db
.select()
.from(branches)
const rows = await this.selectWithRelations()
.where(eq(branches.code, code))
.limit(1);
const row = rows[0];
@@ -87,7 +95,7 @@ export class BranchesRepository {
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.toDomain(row);
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -151,7 +159,7 @@ export class BranchesRepository {
if (!row) {
throw new NotFoundException('Branch not found');
}
return this.toDomain(row);
return this.requireById(row.id);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -176,7 +184,7 @@ export class BranchesRepository {
if (!row) {
throw new NotFoundException('Branch not found');
}
return this.toDomain(row);
return this.requireById(row.id);
}
async bulkUpdateStatus(
@@ -221,6 +229,28 @@ export class BranchesRepository {
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 {
const parts: SQL[] = [];
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 {
id: row.id,
code: row.code,
name: row.name,
phone: PhoneNumber.create(row.phone),
address: row.address,
latitude: row.latitude,
longitude: row.longitude,
workingDaysStart: row.workingDaysStart,
workingDaysEnd: row.workingDaysEnd,
workingHoursStart: row.workingHoursStart,
workingHoursEnd: row.workingHoursEnd,
nfcId: row.nfcId,
divisionId: row.divisionId,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
id: branch.id,
code: branch.code,
name: branch.name,
phone: PhoneNumber.create(branch.phone),
address: branch.address,
latitude: branch.latitude,
longitude: branch.longitude,
workingDaysStart: branch.workingDaysStart,
workingDaysEnd: branch.workingDaysEnd,
workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId,
divisionId: branch.divisionId,
status: Status.create(branch.status),
createdAt: DateTime.fromUnixMs(branch.createdAt),
updatedAt: DateTime.fromUnixMs(branch.updatedAt),
createdBy: branch.createdBy,
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 {
const err = error as { code?: string; constraint?: string };
if (err.code === '23505') {
@@ -44,6 +44,9 @@ describe('BranchesService', () => {
updatedAt: now,
createdBy: '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 = {
@@ -92,8 +95,14 @@ describe('BranchesService', () => {
phone: '+6281234567890',
status: 'draft',
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('division');
expect(service.visibleFields).not.toContain('divisionId');
});
it('findById throws when missing', async () => {
@@ -108,6 +117,12 @@ describe('BranchesService', () => {
const result = await service.findById('br-1');
expect(result.id).toBe('br-1');
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 () => {
@@ -54,7 +54,7 @@ const VISIBLE_FIELDS = [
'workingHoursStart',
'workingHoursEnd',
'nfcId',
'divisionId',
'division',
'status',
'createdAt',
'updatedAt',
@@ -341,15 +341,31 @@ export class BranchesService {
workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId,
divisionId: branch.divisionId,
division: this.toDivisionItem(branch.division),
status: branch.status.value,
createdAt: branch.createdAt.value,
updatedAt: branch.updatedAt.value,
createdBy: branch.createdBy,
updatedBy: branch.updatedBy,
createdBy: this.toUserItem(branch.createdByUser),
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[] {
return VISIBLE_FIELDS;
}
@@ -14,7 +14,11 @@ import {
MaxLength,
Min,
} 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 {
BRANCH_ADDRESS_MAX_LENGTH,
@@ -320,8 +324,8 @@ export class BranchDto {
@ApiPropertyOptional({ nullable: true })
nfcId!: string | null;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
divisionId!: string | null;
@ApiPropertyOptional({ type: DefaultRelationDto, nullable: true })
division!: DefaultRelationDto | null;
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@@ -332,9 +336,9 @@ export class BranchDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}