Add divisions management module with database schema and validation

- Introduced `DivisionsModule` to manage organizational divisions, including read and write controllers.
- Created database migrations for the `divisions` table and related constraints.
- Implemented validation for division name and code with corresponding utility functions.
- Added service and repository layers for handling division data operations.
- Developed unit tests for the divisions service, repository, and controllers to ensure functionality and correctness.
- Updated application module to include the new `ConfigurationModule` for better organization.
This commit is contained in:
shancheas
2026-08-24 12:14:46 +07:00
parent 07550b3167
commit a8faea20ff
22 changed files with 2521 additions and 6 deletions
+2
View File
@@ -5,6 +5,7 @@ import { AppService } from './app.service';
import loadEnv from './config/env';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { ConfigurationModule } from './modules/configuration/configuration.module';
import { PrivilegesModule } from './modules/privileges/privileges.module';
import { UsersModule } from './modules/users/users.module';
@@ -18,6 +19,7 @@ import { UsersModule } from './modules/users/users.module';
UsersModule,
AuthModule,
PrivilegesModule,
ConfigurationModule,
],
controllers: [AppController],
providers: [AppService],
+17
View File
@@ -7,6 +7,7 @@ import {
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
import { primaryEntityColumns } from './primary-entity-columns';
@@ -117,6 +118,20 @@ export const privilegeDetails = pgTable(
],
);
/**
* Organizational divisions (primary aggregate).
*/
export const divisions = pgTable(
'divisions',
{
id: uuid('id').defaultRandom().notNull().primaryKey(),
name: varchar('name', { length: 64 }).notNull(),
code: varchar('code', { length: 16 }).notNull(),
...primaryEntityColumns(users),
},
(t) => [uniqueIndex('divisions_code_unique').on(t.code)],
);
export type UserRow = typeof users.$inferSelect;
export type NewUserRow = typeof users.$inferInsert;
export type RefreshTokenRow = typeof refreshTokens.$inferSelect;
@@ -124,3 +139,5 @@ export type NewRefreshTokenRow = typeof refreshTokens.$inferInsert;
export type PrivilegeKeyRow = typeof privilegeKeys.$inferSelect;
export type PrivilegeRow = typeof privileges.$inferSelect;
export type PrivilegeDetailRow = typeof privilegeDetails.$inferSelect;
export type DivisionRow = typeof divisions.$inferSelect;
export type NewDivisionRow = typeof divisions.$inferInsert;
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { DivisionsModule } from './divisions/divisions.module';
@Module({
imports: [DivisionsModule],
exports: [DivisionsModule],
})
export class ConfigurationModule {}
@@ -0,0 +1,61 @@
import {
DIVISION_CODE_MAX_LENGTH,
DIVISION_NAME_MAX_LENGTH,
isValidDivisionCode,
isValidDivisionName,
} from './division-fields';
describe('division fields', () => {
describe('isValidDivisionName', () => {
it.each(['Finance', 'Human Resources', 'A', 'North West Region'])(
'accepts %s',
(name) => {
expect(isValidDivisionName(name)).toBe(true);
},
);
it.each([
'',
'Finance1',
'Human-Resources',
'HR_OPS',
' Finance',
'Finance ',
'Human Resources',
'财务',
])('rejects %s', (name) => {
expect(isValidDivisionName(name)).toBe(false);
});
it('rejects names longer than 64 characters', () => {
expect(
isValidDivisionName('A'.repeat(DIVISION_NAME_MAX_LENGTH + 1)),
).toBe(false);
expect(isValidDivisionName('A'.repeat(DIVISION_NAME_MAX_LENGTH))).toBe(
true,
);
});
});
describe('isValidDivisionCode', () => {
it.each(['FIN', 'FIN_01', 'A', 'ops2', 'A_b_1'])('accepts %s', (code) => {
expect(isValidDivisionCode(code)).toBe(true);
});
it.each(['', 'FIN 01', 'FIN-01', 'FIN.01', ' FIN', 'FIN '])(
'rejects %s',
(code) => {
expect(isValidDivisionCode(code)).toBe(false);
},
);
it('rejects codes longer than 16 characters', () => {
expect(
isValidDivisionCode('A'.repeat(DIVISION_CODE_MAX_LENGTH + 1)),
).toBe(false);
expect(isValidDivisionCode('A'.repeat(DIVISION_CODE_MAX_LENGTH))).toBe(
true,
);
});
});
});
@@ -0,0 +1,26 @@
export const DIVISION_NAME_MAX_LENGTH = 64;
export const DIVISION_CODE_MAX_LENGTH = 16;
/** Letters with single spaces between words. */
export const DIVISION_NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
/** Alphanumeric and underscore; no spaces. */
export const DIVISION_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
export function isValidDivisionName(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= DIVISION_NAME_MAX_LENGTH &&
DIVISION_NAME_PATTERN.test(raw)
);
}
export function isValidDivisionCode(raw: string): boolean {
return (
typeof raw === 'string' &&
raw.length > 0 &&
raw.length <= DIVISION_CODE_MAX_LENGTH &&
DIVISION_CODE_PATTERN.test(raw)
);
}
@@ -0,0 +1,35 @@
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
export type Division = {
readonly id: string;
readonly name: string;
readonly code: string;
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
};
export type CreateDivisionInput = {
readonly name: string;
readonly code: string;
readonly status?: Status;
readonly userId: string;
};
export type UpdateDivisionInput = {
readonly name?: string;
readonly code?: string;
readonly userId: string;
};
export type ListDivisionsFilters = {
readonly name?: string;
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly limit: number;
readonly offset: number;
};
@@ -0,0 +1,34 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DivisionsReadController } from './divisions-read.controller';
import { DivisionsService } from './divisions.service';
describe('DivisionsReadController', () => {
let controller: DivisionsReadController;
const service = {
list: jest.fn(),
findById: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [DivisionsReadController],
providers: [{ provide: DivisionsService, useValue: service }],
}).compile();
controller = moduleRef.get(DivisionsReadController);
});
it('list delegates to the service', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await expect(controller.list({ page: 1 })).resolves.toEqual({
data: [],
total: 0,
});
expect(service.list).toHaveBeenCalledWith({ page: 1 });
});
it('findOne delegates to the service', async () => {
service.findById.mockResolvedValue({ id: 'div-1' });
await expect(controller.findOne('div-1')).resolves.toEqual({ id: 'div-1' });
});
});
@@ -0,0 +1,64 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import {
ApiBearerAuth,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import {
Pagination,
type PaginationResponse,
PaginationMetaDto,
} from '../../../common/http/response';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
import { DivisionsService } from './divisions.service';
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
@ApiTags('divisions')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('divisions')
export class DivisionsReadController {
constructor(private readonly divisionsService: DivisionsService) {}
@Get()
@Pagination()
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'List divisions' })
@ApiOkResponse({
schema: {
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/DivisionDto' },
},
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
},
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
list(
@Query() query: ListDivisionsQueryDto,
): Promise<PaginationResponse<DivisionDto>> {
return this.divisionsService.list(query);
}
@Get(':id')
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'view')
@ApiOperation({ summary: 'Get division detail' })
@ApiOkResponse({ type: DivisionDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<DivisionDto> {
return this.divisionsService.findById(id);
}
}
void PaginationMetaDto;
@@ -0,0 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DivisionsWriteController } from './divisions-write.controller';
import { DivisionsService } from './divisions.service';
describe('DivisionsWriteController', () => {
let controller: DivisionsWriteController;
const service = {
create: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
bulkUpdateStatus: jest.fn(),
importCsv: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const moduleRef: TestingModule = await Test.createTestingModule({
controllers: [DivisionsWriteController],
providers: [{ provide: DivisionsService, useValue: service }],
}).compile();
controller = moduleRef.get(DivisionsWriteController);
});
it('create passes dto fields and user id', async () => {
service.create.mockResolvedValue({ id: 'div-1' });
await controller.create({ name: 'Finance', code: 'FIN' }, 'user-1');
expect(service.create).toHaveBeenCalledWith({
name: 'Finance',
code: 'FIN',
status: undefined,
userId: 'user-1',
});
});
it('update, updateStatus, and delete delegate', async () => {
service.update.mockResolvedValue({ id: 'div-1' });
service.updateStatus.mockResolvedValue({ id: 'div-1' });
service.delete.mockResolvedValue(undefined);
await controller.update('div-1', { name: 'Finance' }, 'user-1');
await controller.updateStatus('div-1', { status: 'active' }, 'user-1');
await controller.delete('div-1');
expect(service.update).toHaveBeenCalled();
expect(service.updateStatus).toHaveBeenCalledWith(
'div-1',
'active',
'user-1',
);
expect(service.delete).toHaveBeenCalledWith('div-1');
});
it('bulk and import delegate', async () => {
service.bulkDelete.mockResolvedValue({ deleted: 1 });
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
service.importCsv.mockResolvedValue({ imported: 1 });
await controller.bulkDelete({ ids: ['div-1'] });
await controller.bulkStatus(
{ ids: ['div-1'], status: 'archived' },
'user-1',
);
await controller.importCsv(
{ buffer: Buffer.from('name,code\nA,A') },
'user-1',
);
expect(service.importCsv).toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import {
BadRequestException,
Body,
Controller,
Delete,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import {
BulkIdsDto,
BulkStatusDto,
CreateDivisionDto,
DivisionDto,
UpdateDivisionDto,
UpdateDivisionStatusDto,
} from './dto/division.dto';
import { DIVISION_PRIVILEGE_KEY } from './divisions-read.controller';
import { DivisionsService } from './divisions.service';
@ApiTags('divisions')
@ApiBearerAuth(BEARER_AUTH_NAME)
@Controller('divisions')
export class DivisionsWriteController {
constructor(private readonly divisionsService: DivisionsService) {}
@Post('import')
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
fileFilter: (_req, file, cb) => {
if (
!file.mimetype.includes('csv') &&
!file.originalname.toLowerCase().endsWith('.csv')
) {
cb(new BadRequestException('Only CSV files are allowed'), false);
return;
}
cb(null, true);
},
}),
)
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
})
@ApiOperation({ summary: 'Import divisions from CSV' })
@ApiOkResponse({
schema: {
properties: { imported: { type: 'number' } },
},
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
importCsv(
@UploadedFile() file: { buffer?: Buffer } | undefined,
@CurrentUser('id') userId: string,
): Promise<{ imported: number }> {
const csv = file?.buffer?.toString('utf8') ?? '';
return this.divisionsService.importCsv(csv, userId);
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Bulk delete divisions' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
return this.divisionsService.bulkDelete(dto.ids);
}
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Bulk update division status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
})
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
bulkStatus(
@Body() dto: BulkStatusDto,
@CurrentUser('id') userId: string,
): Promise<{ updated: number }> {
return this.divisionsService.bulkUpdateStatus(dto.ids, dto.status, userId);
}
@Post()
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'create')
@ApiOperation({ summary: 'Create division' })
@ApiCreatedResponse({ type: DivisionDto })
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
create(
@Body() dto: CreateDivisionDto,
@CurrentUser('id') userId: string,
): Promise<DivisionDto> {
return this.divisionsService.create({
name: dto.name,
code: dto.code,
status: dto.status,
userId,
});
}
@Patch(':id/status')
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update division status' })
@ApiOkResponse({ type: DivisionDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDivisionStatusDto,
@CurrentUser('id') userId: string,
): Promise<DivisionDto> {
return this.divisionsService.updateStatus(id, dto.status, userId);
}
@Patch(':id')
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'update')
@ApiOperation({ summary: 'Update division (not status)' })
@ApiOkResponse({ type: DivisionDto })
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDivisionDto,
@CurrentUser('id') userId: string,
): Promise<DivisionDto> {
return this.divisionsService.update(id, {
name: dto.name,
code: dto.code,
userId,
});
}
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(DIVISION_PRIVILEGE_KEY, 'delete')
@ApiOperation({ summary: 'Delete division' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@ApiUnauthorizedResponse()
@ApiForbiddenResponse()
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.divisionsService.delete(id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DivisionsReadController } from './divisions-read.controller';
import { DivisionsWriteController } from './divisions-write.controller';
import { DivisionsRepository } from './divisions.repository';
import { DivisionsService } from './divisions.service';
@Module({
controllers: [DivisionsReadController, DivisionsWriteController],
providers: [DivisionsRepository, DivisionsService],
exports: [DivisionsService],
})
export class DivisionsModule {}
@@ -0,0 +1,257 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE } from '../../../database/database.module';
import { DivisionsRepository } from './divisions.repository';
describe('DivisionsRepository', () => {
let repository: DivisionsRepository;
const limit = jest.fn();
const orderBy = jest.fn();
const offset = jest.fn();
const where = jest.fn();
const from = jest.fn();
const select = jest.fn();
const returning = jest.fn();
const values = jest.fn();
const insert = jest.fn();
const set = jest.fn();
const update = jest.fn();
const del = jest.fn();
const transaction = jest.fn();
const $dynamic = jest.fn();
const db = {
select,
insert,
update,
delete: del,
transaction,
};
const row = {
id: 'div-1',
name: 'Finance',
code: 'FIN',
status: 'draft',
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
createdBy: 'user-1',
updatedBy: 'user-1',
};
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy }));
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
from.mockImplementation(() => ({
where,
$dynamic,
}));
$dynamic.mockReturnValue({ where });
select.mockImplementation(() => ({ from }));
values.mockReturnValue({ returning });
insert.mockReturnValue({ values });
set.mockReturnValue({ where });
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([row]);
where.mockImplementation(() => ({ limit, orderBy, returning }));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
}).compile();
repository = moduleRef.get(DivisionsRepository);
});
it('findById maps a row to domain Division', async () => {
limit.mockResolvedValueOnce([row]);
const division = await repository.findById('div-1');
expect(division).toMatchObject({
id: 'div-1',
name: 'Finance',
code: 'FIN',
createdBy: 'user-1',
});
expect(division?.status.value).toBe('draft');
expect(division?.createdAt.value).toBe(1_700_000_000_000);
});
it('findById returns null when missing', async () => {
limit.mockResolvedValueOnce([]);
await expect(repository.findById('missing')).resolves.toBeNull();
});
it('findByCode maps a row', async () => {
limit.mockResolvedValueOnce([row]);
const division = await repository.findByCode('FIN');
expect(division?.code).toBe('FIN');
});
it('list returns mapped rows and total', async () => {
select
.mockImplementationOnce(() => ({
from: () => ({
where: () => Promise.resolve([{ total: 1 }]),
}),
}))
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
}));
const result = await repository.list({
name: 'Fin',
code: 'FIN',
status: 'draft',
search: 'fin',
limit: 10,
offset: 0,
});
expect(result.total).toBe(1);
expect(result.data[0].code).toBe('FIN');
});
it('create inserts and maps unique violations', async () => {
returning.mockResolvedValueOnce([row]);
const created = await repository.create({
name: 'Finance',
code: 'FIN',
userId: 'user-1',
});
expect(created.code).toBe('FIN');
returning.mockRejectedValueOnce({ code: '23505' });
await expect(
repository.create({ name: 'Finance', code: 'FIN', userId: 'user-1' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('create rethrows unknown errors', async () => {
returning.mockRejectedValue(new Error('db down'));
await expect(
repository.create({ name: 'Finance', code: 'FIN', userId: 'user-1' }),
).rejects.toThrow('db down');
});
it('createMany returns 0 for an empty batch and inserts otherwise', async () => {
await expect(repository.createMany([])).resolves.toBe(0);
transaction.mockImplementation(
async (fn: (tx: typeof db) => Promise<void>) => {
await fn(db);
},
);
await expect(
repository.createMany([
{ name: 'Finance', code: 'FIN', userId: 'user-1' },
]),
).resolves.toBe(1);
});
it('createMany maps unique violations', async () => {
transaction.mockRejectedValue({ code: '23505' });
await expect(
repository.createMany([
{ name: 'Finance', code: 'FIN', userId: 'user-1' },
]),
).rejects.toBeInstanceOf(ConflictException);
});
it('updateStatus returns the mapped row', async () => {
returning.mockResolvedValueOnce([row]);
const updated = await repository.updateStatus(
'div-1',
Status.create('active'),
'user-1',
);
expect(updated.id).toBe('div-1');
});
it('list without filters still returns data', async () => {
select
.mockImplementationOnce(() => ({
from: () => ({
where: () => Promise.resolve([{ total: 0 }]),
}),
}))
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([]),
}),
}),
}),
}),
}),
}));
const result = await repository.list({ limit: 10, offset: 0 });
expect(result).toEqual({ data: [], total: 0 });
});
it('update throws when missing and maps unique violations', async () => {
limit.mockResolvedValueOnce([]);
await expect(
repository.update('missing', { userId: 'user-1' }),
).rejects.toBeInstanceOf(NotFoundException);
limit.mockResolvedValueOnce([row]);
returning.mockRejectedValueOnce({ code: '23505' });
await expect(
repository.update('div-1', { code: 'FIN2', userId: 'user-1' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('updateStatus throws when missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(
repository.updateStatus('missing', Status.create('active'), 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('delete throws when missing', async () => {
returning.mockResolvedValueOnce([]);
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
await expect(
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
).resolves.toBe(0);
await expect(repository.bulkDelete([])).resolves.toBe(0);
});
it('bulkUpdateStatus and bulkDelete return affected counts', async () => {
returning.mockResolvedValue([{ id: 'div-1' }, { id: 'div-2' }]);
await expect(
repository.bulkUpdateStatus(
['div-1', 'div-2'],
Status.create('active'),
'user-1',
),
).resolves.toBe(2);
returning.mockResolvedValue([{ id: 'div-1' }]);
await expect(repository.bulkDelete(['div-1'])).resolves.toBe(1);
});
it('extendListQuery is a passthrough hook', () => {
const qb = { join: true };
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
});
});
@@ -0,0 +1,258 @@
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { divisions, type DivisionRow } from '../../../database/schema';
import type {
CreateDivisionInput,
Division,
ListDivisionsFilters,
UpdateDivisionInput,
} from './division';
@Injectable()
export class DivisionsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
async list(
filters: ListDivisionsFilters,
): Promise<{ data: Division[]; total: number }> {
const where = this.buildListWhere(filters);
const totalRows = await this.db
.select({ total: count() })
.from(divisions)
.where(where);
const totalRow = totalRows[0];
let qb = this.db.select().from(divisions).$dynamic();
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(divisions.code))
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row)),
total: Number(totalRow?.total ?? 0),
};
}
/**
* Hook for modules to add joins/extra predicates without forking list.
*/
extendListQuery<T>(qb: T, filters: ListDivisionsFilters): T {
void filters;
return qb;
}
async findById(id: string): Promise<Division | null> {
const rows: DivisionRow[] = await this.db
.select()
.from(divisions)
.where(eq(divisions.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async findByCode(code: string): Promise<Division | null> {
const rows = await this.db
.select()
.from(divisions)
.where(eq(divisions.code, code))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
}
async create(input: CreateDivisionInput): Promise<Division> {
const now = DateTime.fromUnixMs(Date.now());
const status = input.status ?? Status.create(Status.DEFAULT);
try {
const inserted = await this.db
.insert(divisions)
.values({
name: input.name,
code: input.code,
status: status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
})
.returning();
const row = inserted[0];
return this.toDomain(row);
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async createMany(inputs: CreateDivisionInput[]): Promise<number> {
if (inputs.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
try {
await this.db.transaction(async (tx) => {
for (const input of inputs) {
const status = input.status ?? Status.create(Status.DEFAULT);
await tx.insert(divisions).values({
name: input.name,
code: input.code,
status: status.value,
createdAt: now.value,
updatedAt: now.value,
createdBy: input.userId,
updatedBy: input.userId,
});
}
});
return inputs.length;
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async update(id: string, input: UpdateDivisionInput): Promise<Division> {
const existing = await this.findById(id);
if (!existing) {
throw new NotFoundException('Division not found');
}
const now = DateTime.fromUnixMs(Date.now());
try {
const updated = await this.db
.update(divisions)
.set({
name: input.name ?? existing.name,
code: input.code ?? existing.code,
updatedAt: now.value,
updatedBy: input.userId,
})
.where(eq(divisions.id, id))
.returning();
const row = updated[0];
return this.toDomain(row);
} catch (error) {
this.rethrowUniqueViolation(error);
}
}
async updateStatus(
id: string,
status: Status,
userId: string,
): Promise<Division> {
const now = DateTime.fromUnixMs(Date.now());
const updated = await this.db
.update(divisions)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(eq(divisions.id, id))
.returning();
const row = updated[0];
if (!row) {
throw new NotFoundException('Division not found');
}
return this.toDomain(row);
}
async bulkUpdateStatus(
ids: string[],
status: Status,
userId: string,
): Promise<number> {
if (ids.length === 0) {
return 0;
}
const now = DateTime.fromUnixMs(Date.now());
const rows = await this.db
.update(divisions)
.set({
status: status.value,
updatedAt: now.value,
updatedBy: userId,
})
.where(inArray(divisions.id, ids))
.returning({ id: divisions.id });
return rows.length;
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(divisions)
.where(eq(divisions.id, id))
.returning({ id: divisions.id });
if (deleted.length === 0) {
throw new NotFoundException('Division not found');
}
}
async bulkDelete(ids: string[]): Promise<number> {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(divisions)
.where(inArray(divisions.id, ids))
.returning({ id: divisions.id });
return deleted.length;
}
private buildListWhere(filters: ListDivisionsFilters): SQL | undefined {
const parts: SQL[] = [];
if (filters.name) {
parts.push(ilike(divisions.name, `%${filters.name}%`));
}
if (filters.code) {
parts.push(ilike(divisions.code, `%${filters.code}%`));
}
if (filters.status) {
parts.push(eq(divisions.status, filters.status));
}
if (filters.search) {
const search = or(
ilike(divisions.name, `%${filters.search}%`),
ilike(divisions.code, `%${filters.search}%`),
);
if (search) {
parts.push(search);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: DivisionRow): Division {
return {
id: row.id,
name: row.name,
code: row.code,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
}
private rethrowUniqueViolation(error: unknown): never {
const err = error as { code?: string };
if (err.code === '23505') {
throw new ConflictException('Division code already exists');
}
throw error;
}
}
@@ -0,0 +1,259 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { Division } from './division';
import { DivisionsRepository } from './divisions.repository';
import { DivisionsService } from './divisions.service';
describe('DivisionsService', () => {
let service: DivisionsService;
let repository: jest.Mocked<
Pick<
DivisionsRepository,
| 'list'
| 'findById'
| 'create'
| 'createMany'
| 'update'
| 'updateStatus'
| 'bulkUpdateStatus'
| 'delete'
| 'bulkDelete'
>
>;
const now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: Division = {
id: 'div-1',
name: 'Finance',
code: 'FIN',
status: Status.create('draft'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
};
beforeEach(async () => {
repository = {
list: jest.fn(),
findById: jest.fn(),
create: jest.fn(),
createMany: jest.fn(),
update: jest.fn(),
updateStatus: jest.fn(),
bulkUpdateStatus: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
};
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
DivisionsService,
{ provide: DivisionsRepository, useValue: repository },
],
}).compile();
service = moduleRef.get(DivisionsService);
});
it('list maps visible fields and pagination', async () => {
repository.list.mockResolvedValue({ data: [sample], total: 1 });
const result = await service.list({ page: 1, limit: 10 });
expect(result.total).toBe(1);
expect(result.data[0]).toMatchObject({
id: 'div-1',
name: 'Finance',
code: 'FIN',
status: 'draft',
createdAt: now.value,
});
expect(service.visibleFields).toContain('status');
});
it('findById throws when missing', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create defaults status to draft and trims fields', async () => {
repository.create.mockResolvedValue(sample);
await service.create({
name: 'Finance',
code: 'FIN',
userId: 'user-1',
});
expect(repository.create).toHaveBeenCalled();
const arg = repository.create.mock.calls[0][0];
expect(arg.status?.value).toBe('draft');
expect(arg.name).toBe('Finance');
expect(arg.code).toBe('FIN');
});
it('create uses provided status', async () => {
repository.create.mockResolvedValue(sample);
await service.create({
name: 'Finance',
code: 'FIN',
status: 'active',
userId: 'user-1',
});
const arg = repository.create.mock.calls[0][0];
expect(arg.status?.value).toBe('active');
});
it('create rejects invalid name', async () => {
await expect(
service.create({
name: 'Finance1',
code: 'FIN',
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('create rejects invalid code', async () => {
await expect(
service.create({
name: 'Finance',
code: 'FIN 01',
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.create).not.toHaveBeenCalled();
});
it('update rejects status field', async () => {
await expect(
service.update('div-1', {
status: 'active',
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('updateStatus updates via repository', async () => {
repository.updateStatus.mockResolvedValue(sample);
await service.updateStatus('div-1', 'active', 'user-1');
expect(repository.updateStatus).toHaveBeenCalledWith(
'div-1',
expect.objectContaining({ value: 'active' }),
'user-1',
);
});
it('importCsv creates rows and fails batch on invalid status', async () => {
await expect(
service.importCsv('name,code,status\nFinance,FIN,nope', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.createMany).not.toHaveBeenCalled();
});
it('importCsv fails batch on invalid name or code', async () => {
await expect(
service.importCsv('name,code\nFinance1,FIN', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.importCsv('name,code\nFinance,FIN 01', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.createMany).not.toHaveBeenCalled();
});
it('importCsv imports valid rows', async () => {
repository.createMany.mockResolvedValue(1);
const result = await service.importCsv(
'name,code,status\nFinance,FIN,draft',
'user-1',
);
expect(result.imported).toBe(1);
expect(repository.createMany).toHaveBeenCalledTimes(1);
});
it('findById returns mapped item', async () => {
repository.findById.mockResolvedValue(sample);
const result = await service.findById('div-1');
expect(result.id).toBe('div-1');
expect(result.status).toBe('draft');
});
it('update trims and validates name and code', async () => {
repository.update.mockResolvedValue({
...sample,
name: 'Operations',
code: 'OPS',
});
await service.update('div-1', {
name: 'Operations',
code: 'OPS',
userId: 'user-1',
});
expect(repository.update).toHaveBeenCalledWith(
'div-1',
expect.objectContaining({ name: 'Operations', code: 'OPS' }),
);
});
it('update rejects invalid name or code', async () => {
await expect(
service.update('div-1', { name: 'Ops1', userId: 'user-1' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update('div-1', { code: 'OPS 1', userId: 'user-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
repository.delete.mockResolvedValue(undefined);
repository.bulkDelete.mockResolvedValue(2);
repository.bulkUpdateStatus.mockResolvedValue(2);
await service.delete('div-1');
await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({
deleted: 2,
});
await expect(
service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'),
).resolves.toEqual({ updated: 2 });
});
it('importCsv rejects empty, oversized, and headerless files', async () => {
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
service.importCsv('code\nFIN', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
const huge = [
'name,code',
...Array.from({ length: 501 }, () => 'A,A'),
].join('\n');
await expect(service.importCsv(huge, 'user-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('importCsv rejects missing name or code cells', async () => {
await expect(
service.importCsv('name,code\n,FIN', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('importCsv rejects invalid status without echoing it', async () => {
await expect(
service.importCsv('name,code,status\nFinance,FIN,nope', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(repository.createMany).not.toHaveBeenCalled();
});
it('update with only userId still calls repository', async () => {
repository.update.mockResolvedValue(sample);
await service.update('div-1', { userId: 'user-1' });
expect(repository.update).toHaveBeenCalledWith(
'div-1',
expect.objectContaining({ userId: 'user-1' }),
);
});
});
@@ -0,0 +1,248 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { Status } from '../../../common/value-objects/status/status';
import type {
CreateDivisionInput,
Division,
UpdateDivisionInput,
} from './division';
import { isValidDivisionCode, isValidDivisionName } from './division-fields';
import { DivisionsRepository } from './divisions.repository';
export type ListDivisionsQuery = {
readonly name?: string;
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
};
const VISIBLE_FIELDS = [
'id',
'name',
'code',
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
@Injectable()
export class DivisionsService {
constructor(private readonly divisionsRepository: DivisionsRepository) {}
async list(
query: ListDivisionsQuery,
): Promise<PaginationResponse<ReturnType<DivisionsService['toListItem']>>> {
const page = toListPage(query);
const { data, total } = await this.divisionsRepository.list({
name: query.name,
code: query.code,
status: query.status,
search: query.search,
limit: page.limit,
offset: page.offset,
});
return {
data: data.map((item) => this.toListItem(item)),
total,
};
}
async findById(
id: string,
): Promise<ReturnType<DivisionsService['toListItem']>> {
const division = await this.divisionsRepository.findById(id);
if (!division) {
throw new NotFoundException('Division not found');
}
return this.toListItem(division);
}
async create(input: {
name: string;
code: string;
status?: string;
userId: string;
}): Promise<ReturnType<DivisionsService['toListItem']>> {
const name = this.assertName(input.name);
const code = this.assertCode(input.code);
const created = await this.divisionsRepository.create({
name,
code,
status: input.status
? Status.create(input.status)
: Status.create(Status.DEFAULT),
userId: input.userId,
});
return this.toListItem(created);
}
async update(
id: string,
input: {
name?: string;
code?: string;
status?: unknown;
userId: string;
},
): Promise<ReturnType<DivisionsService['toListItem']>> {
if (input.status !== undefined) {
throw new BadRequestException('status cannot be updated via PATCH');
}
const payload: UpdateDivisionInput = {
name: input.name !== undefined ? this.assertName(input.name) : undefined,
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
userId: input.userId,
};
const updated = await this.divisionsRepository.update(id, payload);
return this.toListItem(updated);
}
async updateStatus(
id: string,
statusRaw: string,
userId: string,
): Promise<ReturnType<DivisionsService['toListItem']>> {
const status = Status.create(statusRaw);
const updated = await this.divisionsRepository.updateStatus(
id,
status,
userId,
);
return this.toListItem(updated);
}
async bulkUpdateStatus(
ids: string[],
statusRaw: string,
userId: string,
): Promise<{ updated: number }> {
const status = Status.create(statusRaw);
const updated = await this.divisionsRepository.bulkUpdateStatus(
ids,
status,
userId,
);
return { updated };
}
async delete(id: string): Promise<void> {
await this.divisionsRepository.delete(id);
}
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
const deleted = await this.divisionsRepository.bulkDelete(ids);
return { deleted };
}
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
const lines = csv
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) {
throw new BadRequestException('CSV is empty');
}
if (lines.length > 501) {
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
}
const header = lines[0].split(',').map((h) => h.trim().toLowerCase());
const nameIdx = header.indexOf('name');
const codeIdx = header.indexOf('code');
const statusIdx = header.indexOf('status');
if (nameIdx < 0 || codeIdx < 0) {
throw new BadRequestException('CSV must include name and code headers');
}
const errors: string[] = [];
const rows: { name: string; code: string; status?: string }[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map((c) => c.trim());
const name = cols[nameIdx] ?? '';
const code = cols[codeIdx] ?? '';
const status = statusIdx >= 0 ? cols[statusIdx] : undefined;
if (!name || !code) {
errors.push(`row ${i + 1}: name and code are required`);
continue;
}
if (!isValidDivisionName(name)) {
errors.push(`row ${i + 1}: invalid name`);
continue;
}
if (!isValidDivisionCode(code)) {
errors.push(`row ${i + 1}: invalid code`);
continue;
}
if (status) {
try {
Status.create(status);
} catch {
errors.push(`row ${i + 1}: invalid status`);
continue;
}
}
rows.push({ name, code, status: status || undefined });
}
if (errors.length > 0) {
throw new BadRequestException({
message: 'CSV validation failed',
errors,
});
}
const inputs: CreateDivisionInput[] = rows.map((row) => ({
name: row.name,
code: row.code,
status: row.status
? Status.create(row.status)
: Status.create(Status.DEFAULT),
userId,
}));
await this.divisionsRepository.createMany(inputs);
return { imported: rows.length };
}
toListItem(division: Division) {
return {
id: division.id,
name: division.name,
code: division.code,
status: division.status.value,
createdAt: division.createdAt.value,
updatedAt: division.updatedAt.value,
createdBy: division.createdBy,
updatedBy: division.updatedBy,
};
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
private assertName(raw: string): string {
const name = raw.trim();
if (!isValidDivisionName(name)) {
throw new BadRequestException('Invalid division name');
}
return name;
}
private assertCode(raw: string): string {
const code = raw.trim();
if (!isValidDivisionCode(code)) {
throw new BadRequestException('Invalid division code');
}
return code;
}
}
@@ -0,0 +1,146 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayNotEmpty,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
MaxLength,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
DIVISION_CODE_MAX_LENGTH,
DIVISION_CODE_PATTERN,
DIVISION_NAME_MAX_LENGTH,
DIVISION_NAME_PATTERN,
} from '../division-fields';
export class CreateDivisionDto {
@ApiProperty({
example: 'Human Resources',
maxLength: DIVISION_NAME_MAX_LENGTH,
})
@IsString()
@IsNotEmpty()
@MaxLength(DIVISION_NAME_MAX_LENGTH)
@Matches(DIVISION_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name!: string;
@ApiProperty({ example: 'HR', maxLength: DIVISION_CODE_MAX_LENGTH })
@IsString()
@IsNotEmpty()
@MaxLength(DIVISION_CODE_MAX_LENGTH)
@Matches(DIVISION_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code!: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
}
export class UpdateDivisionDto {
@ApiPropertyOptional({ example: 'Human Resources' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(DIVISION_NAME_MAX_LENGTH)
@Matches(DIVISION_NAME_PATTERN, {
message: 'name must contain only letters and spaces',
})
name?: string;
@ApiPropertyOptional({ example: 'HR' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(DIVISION_CODE_MAX_LENGTH)
@Matches(DIVISION_CODE_PATTERN, {
message: 'code must contain only letters, numbers, and underscores',
})
code?: string;
}
export class UpdateDivisionStatusDto {
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class BulkIdsDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
}
export class BulkStatusDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
ids!: string[];
@ApiProperty({ enum: CORE_STATUSES })
@IsIn([...CORE_STATUSES])
status!: string;
}
export class ListDivisionsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
code?: string;
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@IsIn([...CORE_STATUSES])
status?: string;
@ApiPropertyOptional({
description: 'Case-insensitive match on name or code',
})
@IsOptional()
@IsString()
search?: string;
}
export class DivisionDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
code!: string;
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@ApiProperty({ description: 'Unix ms' })
createdAt!: number;
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
}