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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user