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`
|
- 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
|
||||||
|
|||||||
@@ -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_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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user