From 627aeac4a03ee4add2728b559338f2c260df21d2 Mon Sep 17 00:00:00 2001 From: shancheas Date: Thu, 27 Aug 2026 15:07:20 +0700 Subject: [PATCH] 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. --- docs/api.md | 85 +++++++++++++++++++ .../employees/dto/employee.dto.spec.ts | 34 ++++++++ .../employees/dto/employee.dto.ts | 29 ++++++- .../configuration/employees/employee.ts | 13 +-- .../employees-read.controller.spec.ts | 8 ++ .../employees/employees.repository.spec.ts | 4 +- .../employees/employees.repository.ts | 6 +- .../employees/employees.service.spec.ts | 43 +++++----- .../employees/employees.service.ts | 14 ++- src/modules/users/users-writer.ts | 14 +++ src/modules/users/users.module.ts | 10 ++- src/modules/users/users.service.ts | 3 +- test/employees.e2e-spec.ts | 54 ++++++++++++ 13 files changed, 270 insertions(+), 47 deletions(-) create mode 100644 docs/api.md create mode 100644 src/modules/configuration/employees/dto/employee.dto.spec.ts create mode 100644 src/modules/users/users-writer.ts diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e724f6a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,85 @@ +# TrackGo HTTP API + +JSON keys are camelCase. IDs are UUID v4. List endpoints return `{ data, meta }`. + +List query includes shared pagination (`page`/`limit` or `offset`/`limit`) plus **`orderBy`** (resource field name) and **`orderType`** (`ASC` or `DESC`, default `ASC`). Unknown `orderBy` values are rejected. Defaults when omitted: users `username`; cycles `cycleNumber`; plans `date`; privilege-keys `sortOrder` then `code`; other lists `code`. Foreign keys on list/detail responses are nested objects (`{ id, code, name }` or `{ id, username }` / `{ id, code }`), not bare UUIDs. + +List filters: `username`, `privilegeId`, `status`, `search` (username), `orderBy`, `orderType`. + +## Auth + +### `POST /auth/register` — public — `201` + +Creates a **draft** user. Does **not** issue tokens. + +```json +{ "username": "alice", "password": "password123" } +``` + +Response: `{ "id": "uuid", "username": "alice", "status": "draft" }`. + +Activate with `PATCH /users/:id/status` `{ "status": "active" }` (or SQL bootstrap) then `POST /auth/login`. + +### `POST /auth/login` — public — `200` + +Same body as register. Returns `{ accessToken, refreshToken }`. + +Login, refresh, and JWT validation require `user.status === "active"`. If the user is assigned to an employee, that employee must also be `active`. Failures use `401` with a generic credentials message. + +### `POST /auth/refresh` — public — `200` + +### `POST /auth/revoke` — public — `204` + +### `GET /auth/me` — bearer — `200` + +## Users + +Key: `USERS`. **Standard CRUD + import** (list, detail, create, update, status, delete, bulk-delete, bulk-status, import). Extra: `PATCH /users/:id/privilege`. + +| Method | Path | Action | Status | +| --- | --- | --- | --- | +| `GET` | `/users` | view | 200 | +| `GET` | `/users/:id` | view | 200 | +| `POST` | `/users` | create | 201 | +| `PATCH` | `/users/:id` | update | 200 | +| `PATCH` | `/users/:id/status` | update | 200 | +| `PATCH` | `/users/:id/privilege` | update | 200 | +| `DELETE` | `/users/:id` | delete | 204 | +| `POST` | `/users/bulk-delete` | delete | 200 | +| `POST` | `/users/bulk-status` | update | 200 | +| `POST` | `/users/import` | import | 200 | + +**Create:** `{ username, password, privilegeId?, status?, employeeId? }`. Username 3–32, `^[a-zA-Z0-9_]+$`, stored lowercased. Password 8–72, write-only. Omit status → `draft`. Never send `isSuperadmin` / `passwordHash`. + +Optional `employeeId` links an existing employee. Unique assigned user → `409`. + +**Update** `PATCH /users/:id`: `username?`, `password?`, `privilegeId?` (`null` clears), `employeeId?` (`null` unlinks). No `status`. `employeeId` reassigns the linked employee. + +**Privilege:** `{ privilegeId }` (`null` clears). Assigned privilege must be **active**. Response is the full `UserDto`. + +List filters: `username`, `privilegeId`, `status`, `search` (username). + +**DTO:** + +```json +{ + "id": "uuid", + "username": "alice", + "isSuperadmin": false, + "privilege": { "id": "uuid", "code": "ADMIN", "name": "Administrator" }, + "employee": { "id": "uuid", "code": "EMP_01", "name": "Ada Lovelace" }, + "status": "active", + "createdAt": 1710000000000, + "updatedAt": 1710000000000, + "createdBy": { "id": "uuid", "username": "admin" }, + "updatedBy": { "id": "uuid", "username": "admin" } +} +``` + +`privilege` / `employee` may be `null`. CSV required: `username`, `password`. Optional: `privilegeId`, `status`. Delete of a user still referenced as `created_by` / `updated_by` → `409`. + +Bootstrap: first user is draft until `UPDATE users SET status = 'active'`. + +## Employees + +Create/update optional `userId` (assign an existing login user) or nested `user` (`id?`, `username?`, `password?`). Nested `user` without `id` creates a login user (`username` + `password` required) or updates the currently linked username. `user.id` / `userId` links an existing user; `username` may be updated, but `password` is rejected (use `PATCH /users/:id`). Nested `user` cannot set `privilegeId`. `user: null` or `userId: null` unlinks. Users link the other way with `employeeId`. DTO nests `user: { id, username } | null`. List filter `userId`. List filter `position` as one or more of `sales` | `driver` | `crew` (`?position=sales&position=driver`). CSV optional `userId`. Unique assigned user → `409`. diff --git a/src/modules/configuration/employees/dto/employee.dto.spec.ts b/src/modules/configuration/employees/dto/employee.dto.spec.ts new file mode 100644 index 0000000..45bc6e5 --- /dev/null +++ b/src/modules/configuration/employees/dto/employee.dto.spec.ts @@ -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); + }); +}); diff --git a/src/modules/configuration/employees/dto/employee.dto.ts b/src/modules/configuration/employees/dto/employee.dto.ts index 31d7218..a6c7f5c 100644 --- a/src/modules/configuration/employees/dto/employee.dto.ts +++ b/src/modules/configuration/employees/dto/employee.dto.ts @@ -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() diff --git a/src/modules/configuration/employees/employee.ts b/src/modules/configuration/employees/employee.ts index bdc211b..ee5cee0 100644 --- a/src/modules/configuration/employees/employee.ts +++ b/src/modules/configuration/employees/employee.ts @@ -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; diff --git a/src/modules/configuration/employees/employees-read.controller.spec.ts b/src/modules/configuration/employees/employees-read.controller.spec.ts index 537957a..6f56113 100644 --- a/src/modules/configuration/employees/employees-read.controller.spec.ts +++ b/src/modules/configuration/employees/employees-read.controller.spec.ts @@ -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({ diff --git a/src/modules/configuration/employees/employees.repository.spec.ts b/src/modules/configuration/employees/employees.repository.spec.ts index 097ba71..81a0319 100644 --- a/src/modules/configuration/employees/employees.repository.spec.ts +++ b/src/modules/configuration/employees/employees.repository.spec.ts @@ -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, diff --git a/src/modules/configuration/employees/employees.repository.ts b/src/modules/configuration/employees/employees.repository.ts index 67de915..bbb88e8 100644 --- a/src/modules/configuration/employees/employees.repository.ts +++ b/src/modules/configuration/employees/employees.repository.ts @@ -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)); diff --git a/src/modules/configuration/employees/employees.service.spec.ts b/src/modules/configuration/employees/employees.service.spec.ts index 837679f..d8923a6 100644 --- a/src/modules/configuration/employees/employees.service.spec.ts +++ b/src/modules/configuration/employees/employees.service.spec.ts @@ -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 - >; + let usersService: { + findById: jest.MockedFunction; + createManaged: jest.MockedFunction; + update: jest.MockedFunction; + }; 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>); + 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>); + 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>); + 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>); + usersService.findById.mockResolvedValue({ id: 'user-2' }); repository.findByUserId.mockResolvedValue({ ...sample, id: 'emp-other', diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index 97c6438..cc091a5 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -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( diff --git a/src/modules/users/users-writer.ts b/src/modules/users/users-writer.ts new file mode 100644 index 0000000..ca4bb16 --- /dev/null +++ b/src/modules/users/users-writer.ts @@ -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; + createManaged: (input: { + username: string; + password: string; + actorUserId: string; + }) => Promise<{ id: string }>; +}; diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index f338868..f276a97 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -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 {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 48640e0..d4fe6ed 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -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, diff --git a/test/employees.e2e-spec.ts b/test/employees.e2e-spec.ts index d820ed2..89e987e 100644 --- a/test/employees.e2e-spec.ts +++ b/test/employees.e2e-spec.ts @@ -202,6 +202,60 @@ describe('Employees (e2e)', () => { .expect(204); }); + it('lists employees filtered by an array of positions', async () => { + const suffix = Date.now().toString().slice(-6); + const auth = { Authorization: `Bearer ${adminAccessToken}` }; + const base = { + name: 'Ada Lovelace', + phone: '+6281234567890', + }; + + const sales = await request(app.getHttpServer()) + .post('/employees') + .set(auth) + .send({ ...base, code: `PS_${suffix}`, position: 'sales' }) + .expect(201); + const driver = await request(app.getHttpServer()) + .post('/employees') + .set(auth) + .send({ ...base, code: `PD_${suffix}`, position: 'driver' }) + .expect(201); + const crew = await request(app.getHttpServer()) + .post('/employees') + .set(auth) + .send({ ...base, code: `PC_${suffix}`, position: 'crew' }) + .expect(201); + + const salesId = (sales.body as { id: string }).id; + const driverId = (driver.body as { id: string }).id; + const crewId = (crew.body as { id: string }).id; + + const single = await request(app.getHttpServer()) + .get(`/employees?position=sales&code=${suffix}`) + .set(auth) + .expect(200); + const singleIds = (single.body as { data: { id: string }[] }).data.map( + (row) => row.id, + ); + expect(singleIds).toEqual([salesId]); + + const multi = await request(app.getHttpServer()) + .get(`/employees?position=sales&position=driver&code=${suffix}`) + .set(auth) + .expect(200); + const multiIds = (multi.body as { data: { id: string }[] }).data.map( + (row) => row.id, + ); + expect(multiIds).toEqual(expect.arrayContaining([salesId, driverId])); + expect(multiIds).toHaveLength(2); + expect(multiIds).not.toContain(crewId); + + await request(app.getHttpServer()) + .get('/employees?position=pilot') + .set(auth) + .expect(400); + }); + it('imports employees from CSV', async () => { const suffix = Date.now().toString().slice(-6); const csv =