Add API documentation for TrackGo HTTP API and enhance employee management features

- Created a new `api.md` file detailing the TrackGo HTTP API, including authentication, user management, and employee operations.
- Updated `Employee` type to simplify user relation handling by replacing `UserRelation` with a more concise structure.
- Enhanced filtering capabilities in employee queries to support an array of positions.
- Refactored employee-related services and repositories to accommodate the new position filtering logic.
- Added unit and e2e tests to validate the new API documentation and employee management functionalities.
This commit is contained in:
shancheas
2026-08-27 15:07:20 +07:00
parent 4c45a4371e
commit 627aeac4a0
13 changed files with 270 additions and 47 deletions
@@ -0,0 +1,34 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ListEmployeesQueryDto } from './employee.dto';
describe('ListEmployeesQueryDto', () => {
it('coerces a single position query value into an array', async () => {
const dto = plainToInstance(ListEmployeesQueryDto, { position: 'sales' });
expect(dto.position).toEqual(['sales']);
expect(await validate(dto)).toHaveLength(0);
});
it('accepts multiple positions', async () => {
const dto = plainToInstance(ListEmployeesQueryDto, {
position: ['sales', 'driver'],
});
expect(dto.position).toEqual(['sales', 'driver']);
expect(await validate(dto)).toHaveLength(0);
});
it('rejects an invalid position in the array', async () => {
const dto = plainToInstance(ListEmployeesQueryDto, {
position: ['pilot'],
});
expect(await validate(dto)).not.toHaveLength(0);
});
it('drops empty entries and deduplicates positions', async () => {
const dto = plainToInstance(ListEmployeesQueryDto, {
position: ['sales', '', 'sales'],
});
expect(dto.position).toEqual(['sales']);
expect(await validate(dto)).toHaveLength(0);
});
});
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayNotEmpty,
IsArray,
IsIn,
@@ -199,10 +200,30 @@ export class ListEmployeesQueryDto extends PaginationQueryDto {
@IsString()
phone?: string;
@ApiPropertyOptional({ enum: EMPLOYEE_POSITIONS })
@ApiPropertyOptional({
enum: EMPLOYEE_POSITIONS,
isArray: true,
description: 'Filter by one or more positions',
})
@IsOptional()
@IsIn([...EMPLOYEE_POSITIONS])
position?: string;
@Transform(({ value }: { value: unknown }): string[] | undefined => {
if (value === undefined || value === null || value === '') {
return undefined;
}
const raw = Array.isArray(value) ? value : [value];
const items = [
...new Set(
raw.filter(
(item): item is string => typeof item === 'string' && item !== '',
),
),
];
return items.length > 0 ? items : undefined;
})
@IsArray()
@ArrayMaxSize(EMPLOYEE_POSITIONS.length)
@IsIn([...EMPLOYEE_POSITIONS], { each: true })
position?: string[];
@ApiPropertyOptional({ enum: CORE_STATUSES })
@IsOptional()
@@ -1,4 +1,3 @@
import type { UserRelation } from '../../../common/http/response';
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,8 +14,8 @@ export type Employee = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
readonly createdByUser: { readonly id: string; readonly username: string };
readonly updatedByUser: { readonly id: string; readonly username: string };
readonly userId: string | null;
readonly user: { readonly id: string; readonly username: string } | null;
};
@@ -40,17 +39,11 @@ export type UpdateEmployeeInput = {
readonly assignedUserId?: string | null;
};
export type EmployeeUserWrite = {
readonly id?: string;
readonly username?: string;
readonly password?: string;
};
export type ListEmployeesFilters = {
readonly code?: string;
readonly name?: string;
readonly phone?: string;
readonly position?: string;
readonly position?: readonly string[];
readonly status?: string;
readonly userId?: string;
readonly search?: string;
@@ -27,6 +27,14 @@ describe('EmployeesReadController', () => {
expect(service.list).toHaveBeenCalledWith({ page: 1 });
});
it('list forwards an array of positions', async () => {
service.list.mockResolvedValue({ data: [], total: 0 });
await controller.list({ position: ['sales', 'driver'] });
expect(service.list).toHaveBeenCalledWith({
position: ['sales', 'driver'],
});
});
it('findOne delegates to the service', async () => {
service.findById.mockResolvedValue({ id: 'emp-1' });
await expect(controller.findOne('emp-1')).resolves.toEqual({
@@ -45,8 +45,6 @@ describe('EmployeesRepository', () => {
userId: null,
};
const joinedRow = { employee: row, user: null };
const createInput = {
code: 'EMP_01',
name: 'Ada Lovelace',
@@ -144,7 +142,7 @@ describe('EmployeesRepository', () => {
name: 'Ada',
code: 'EMP',
phone: '+628',
position: 'sales',
position: ['sales', 'driver'],
status: 'draft',
search: 'ada',
limit: 10,
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { toOrderClauses } from '../../../common/http/response/order-clause';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
@@ -252,8 +252,8 @@ export class EmployeesRepository {
if (filters.phone) {
parts.push(ilike(employees.phone, `%${filters.phone}%`));
}
if (filters.position) {
parts.push(eq(employees.position, filters.position));
if (filters.position && filters.position.length > 0) {
parts.push(inArray(employees.position, [...filters.position]));
}
if (filters.status) {
parts.push(eq(employees.status, filters.status));
@@ -8,7 +8,7 @@ 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';
import type { Employee } from './employee';
import { UsersService } from '../../users/users.service';
import { USERS_WRITER, type UsersWriter } from '../../users/users-writer';
import { EmployeesRepository } from './employees.repository';
import { EmployeesService } from './employees.service';
@@ -29,9 +29,11 @@ describe('EmployeesService', () => {
| 'bulkDelete'
>
>;
let usersService: jest.Mocked<
Pick<UsersService, 'findById' | 'createManaged' | 'update'>
>;
let usersService: {
findById: jest.MockedFunction<UsersWriter['findById']>;
createManaged: jest.MockedFunction<UsersWriter['createManaged']>;
update: jest.MockedFunction<UsersWriter['update']>;
};
const now = DateTime.fromUnixMs(1_700_000_000_000);
const sample: Employee = {
@@ -82,7 +84,7 @@ describe('EmployeesService', () => {
providers: [
EmployeesService,
{ provide: EmployeesRepository, useValue: repository },
{ provide: UsersService, useValue: usersService },
{ provide: USERS_WRITER, useValue: usersService },
],
}).compile();
@@ -107,6 +109,18 @@ describe('EmployeesService', () => {
);
});
it('list forwards an array of positions to the repository', async () => {
repository.list.mockResolvedValue({ data: [], total: 0 });
await service.list({
position: ['sales', 'driver'],
page: 1,
limit: 10,
});
expect(repository.list).toHaveBeenCalledWith(
expect.objectContaining({ position: ['sales', 'driver'] }),
);
});
it('findById throws when missing', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -313,10 +327,7 @@ describe('EmployeesService', () => {
});
it('create creates a user when nested user has no id', async () => {
usersService.createManaged.mockResolvedValue({
id: 'user-9',
username: 'bob',
} as Awaited<ReturnType<UsersService['createManaged']>>);
usersService.createManaged.mockResolvedValue({ id: 'user-9' });
repository.create.mockResolvedValue({
...sample,
userId: 'user-9',
@@ -361,10 +372,7 @@ describe('EmployeesService', () => {
});
it('create links and updates an existing user by nested id', async () => {
usersService.update.mockResolvedValue({
id: 'user-2',
username: 'bobby',
} as Awaited<ReturnType<UsersService['update']>>);
usersService.update.mockResolvedValue({ id: 'user-2' });
repository.create.mockResolvedValue({
...sample,
userId: 'user-2',
@@ -405,10 +413,7 @@ describe('EmployeesService', () => {
userId: 'user-2',
user: { id: 'user-2', username: 'alice' },
});
usersService.update.mockResolvedValue({
id: 'user-2',
username: 'alice2',
} as Awaited<ReturnType<UsersService['update']>>);
usersService.update.mockResolvedValue({ id: 'user-2' });
repository.update.mockResolvedValue({
...sample,
userId: 'user-2',
@@ -449,9 +454,7 @@ describe('EmployeesService', () => {
it('update rejects nested user assigned to another employee', async () => {
repository.findById.mockResolvedValue(sample);
usersService.findById.mockResolvedValue({
id: 'user-2',
} as Awaited<ReturnType<UsersService['findById']>>);
usersService.findById.mockResolvedValue({ id: 'user-2' });
repository.findByUserId.mockResolvedValue({
...sample,
id: 'emp-other',
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -17,7 +18,6 @@ import { Status } from '../../../common/value-objects/status/status';
import type {
CreateEmployeeInput,
Employee,
EmployeeUserWrite,
UpdateEmployeeInput,
} from './employee';
import {
@@ -27,14 +27,14 @@ import {
parseCsvRecord,
type EmployeePosition,
} from './employee-fields';
import { UsersService } from '../../users/users.service';
import { USERS_WRITER, type UsersWriter } from '../../users/users-writer';
import { EmployeesRepository } from './employees.repository';
export type ListEmployeesQuery = {
readonly code?: string;
readonly name?: string;
readonly phone?: string;
readonly position?: string;
readonly position?: readonly string[];
readonly status?: string;
readonly userId?: string;
readonly search?: string;
@@ -61,11 +61,17 @@ const VISIBLE_FIELDS = [
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
interface EmployeeUserWrite {
readonly id?: string;
readonly username?: string;
readonly password?: string;
}
@Injectable()
export class EmployeesService {
constructor(
private readonly employeesRepository: EmployeesRepository,
private readonly usersService: UsersService,
@Inject(USERS_WRITER) private readonly usersService: UsersWriter,
) {}
async list(
+14
View File
@@ -0,0 +1,14 @@
export const USERS_WRITER = Symbol('USERS_WRITER');
export type UsersWriter = {
findById: (id: string) => Promise<{ id: string } | null>;
update: (
id: string,
input: { username?: string; actorUserId: string },
) => Promise<unknown>;
createManaged: (input: {
username: string;
password: string;
actorUserId: string;
}) => Promise<{ id: string }>;
};
+8 -2
View File
@@ -5,11 +5,17 @@ import { UsersReadController } from './users-read.controller';
import { UsersWriteController } from './users-write.controller';
import { UsersRepository } from './users.repository';
import { UsersService } from './users.service';
import { USERS_WRITER } from './users-writer';
@Module({
imports: [PrivilegesModule],
controllers: [UsersReadController, UsersWriteController],
providers: [UsersRepository, UsersService, EmployeesRepository],
exports: [UsersService],
providers: [
UsersRepository,
UsersService,
EmployeesRepository,
{ provide: USERS_WRITER, useExisting: UsersService },
],
exports: [UsersService, USERS_WRITER],
})
export class UsersModule {}
+2 -1
View File
@@ -24,6 +24,7 @@ import {
isValidUsername,
parseCsvRecord,
} from './user-fields';
import type { UsersWriter } from './users-writer';
import { UsersRepository } from './users.repository';
export type ListUsersQuery = {
@@ -54,7 +55,7 @@ const VISIBLE_FIELDS = [
const CSV_REQUIRED_HEADERS = ['username', 'password'] as const;
@Injectable()
export class UsersService {
export class UsersService implements UsersWriter {
constructor(
private readonly usersRepository: UsersRepository,
private readonly privilegesService: PrivilegesService,