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:
@@ -38,6 +38,7 @@ List requirements:
|
||||
- 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`)
|
||||
- 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
|
||||
|
||||
## Write controller
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
```
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('Branches (e2e)', () => {
|
||||
let adminUserId: string;
|
||||
let otherAccessToken: string;
|
||||
let divisionId: string;
|
||||
let divisionCode: string;
|
||||
const divisionName = 'Jakarta Division';
|
||||
|
||||
const payload = {
|
||||
code: `JKT_${Date.now().toString().slice(-6)}`,
|
||||
@@ -103,11 +105,12 @@ describe('Branches (e2e)', () => {
|
||||
.post('/divisions')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
name: 'Jakarta Division',
|
||||
name: divisionName,
|
||||
code: `JD_${Date.now().toString().slice(-6)}`,
|
||||
})
|
||||
.expect(201);
|
||||
divisionId = (division.body as { id: string }).id;
|
||||
divisionCode = (division.body as { code: string }).code;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -137,11 +140,13 @@ describe('Branches (e2e)', () => {
|
||||
name: 'Jakarta Pusat',
|
||||
phone: '+6281234567890',
|
||||
status: 'draft',
|
||||
createdBy: adminUserId,
|
||||
createdBy: { id: adminUserId, username: adminUsername },
|
||||
updatedBy: { id: adminUserId, username: adminUsername },
|
||||
latitude: null,
|
||||
nfcId: null,
|
||||
divisionId: null,
|
||||
division: null,
|
||||
});
|
||||
expect(created.body).not.toHaveProperty('divisionId');
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
@@ -167,7 +172,7 @@ describe('Branches (e2e)', () => {
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
const patched = await request(app.getHttpServer())
|
||||
.patch(`/branches/${id}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
@@ -178,6 +183,15 @@ describe('Branches (e2e)', () => {
|
||||
divisionId,
|
||||
})
|
||||
.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())
|
||||
.patch(`/branches/${id}`)
|
||||
|
||||
Reference in New Issue
Block a user