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:
+85
@@ -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`.
|
||||||
@@ -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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -199,10 +200,30 @@ export class ListEmployeesQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: EMPLOYEE_POSITIONS })
|
@ApiPropertyOptional({
|
||||||
|
enum: EMPLOYEE_POSITIONS,
|
||||||
|
isArray: true,
|
||||||
|
description: 'Filter by one or more positions',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...EMPLOYEE_POSITIONS])
|
@Transform(({ value }: { value: unknown }): string[] | undefined => {
|
||||||
position?: string;
|
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 })
|
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { UserRelation } from '../../../common/http/response';
|
|
||||||
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,8 +14,8 @@ export type Employee = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
readonly createdByUser: UserRelation;
|
readonly createdByUser: { readonly id: string; readonly username: string };
|
||||||
readonly updatedByUser: UserRelation;
|
readonly updatedByUser: { readonly id: string; readonly username: string };
|
||||||
readonly userId: string | null;
|
readonly userId: string | null;
|
||||||
readonly user: { readonly id: string; readonly username: string } | null;
|
readonly user: { readonly id: string; readonly username: string } | null;
|
||||||
};
|
};
|
||||||
@@ -40,17 +39,11 @@ export type UpdateEmployeeInput = {
|
|||||||
readonly assignedUserId?: string | null;
|
readonly assignedUserId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmployeeUserWrite = {
|
|
||||||
readonly id?: string;
|
|
||||||
readonly username?: string;
|
|
||||||
readonly password?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ListEmployeesFilters = {
|
export type ListEmployeesFilters = {
|
||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly phone?: string;
|
readonly phone?: string;
|
||||||
readonly position?: string;
|
readonly position?: readonly string[];
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly userId?: string;
|
readonly userId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ describe('EmployeesReadController', () => {
|
|||||||
expect(service.list).toHaveBeenCalledWith({ page: 1 });
|
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 () => {
|
it('findOne delegates to the service', async () => {
|
||||||
service.findById.mockResolvedValue({ id: 'emp-1' });
|
service.findById.mockResolvedValue({ id: 'emp-1' });
|
||||||
await expect(controller.findOne('emp-1')).resolves.toEqual({
|
await expect(controller.findOne('emp-1')).resolves.toEqual({
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ describe('EmployeesRepository', () => {
|
|||||||
userId: null,
|
userId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const joinedRow = { employee: row, user: null };
|
|
||||||
|
|
||||||
const createInput = {
|
const createInput = {
|
||||||
code: 'EMP_01',
|
code: 'EMP_01',
|
||||||
name: 'Ada Lovelace',
|
name: 'Ada Lovelace',
|
||||||
@@ -144,7 +142,7 @@ describe('EmployeesRepository', () => {
|
|||||||
name: 'Ada',
|
name: 'Ada',
|
||||||
code: 'EMP',
|
code: 'EMP',
|
||||||
phone: '+628',
|
phone: '+628',
|
||||||
position: 'sales',
|
position: ['sales', 'driver'],
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
search: 'ada',
|
search: 'ada',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
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 { attachAuditUsers } from '../../../database/load-user-refs';
|
||||||
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';
|
||||||
@@ -252,8 +252,8 @@ export class EmployeesRepository {
|
|||||||
if (filters.phone) {
|
if (filters.phone) {
|
||||||
parts.push(ilike(employees.phone, `%${filters.phone}%`));
|
parts.push(ilike(employees.phone, `%${filters.phone}%`));
|
||||||
}
|
}
|
||||||
if (filters.position) {
|
if (filters.position && filters.position.length > 0) {
|
||||||
parts.push(eq(employees.position, filters.position));
|
parts.push(inArray(employees.position, [...filters.position]));
|
||||||
}
|
}
|
||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
parts.push(eq(employees.status, 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 { 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';
|
||||||
import type { Employee } from './employee';
|
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 { EmployeesRepository } from './employees.repository';
|
||||||
import { EmployeesService } from './employees.service';
|
import { EmployeesService } from './employees.service';
|
||||||
|
|
||||||
@@ -29,9 +29,11 @@ describe('EmployeesService', () => {
|
|||||||
| 'bulkDelete'
|
| 'bulkDelete'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
let usersService: jest.Mocked<
|
let usersService: {
|
||||||
Pick<UsersService, 'findById' | 'createManaged' | 'update'>
|
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 now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
const sample: Employee = {
|
const sample: Employee = {
|
||||||
@@ -82,7 +84,7 @@ describe('EmployeesService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
EmployeesService,
|
EmployeesService,
|
||||||
{ provide: EmployeesRepository, useValue: repository },
|
{ provide: EmployeesRepository, useValue: repository },
|
||||||
{ provide: UsersService, useValue: usersService },
|
{ provide: USERS_WRITER, useValue: usersService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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 () => {
|
it('findById throws when missing', async () => {
|
||||||
repository.findById.mockResolvedValue(null);
|
repository.findById.mockResolvedValue(null);
|
||||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
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 () => {
|
it('create creates a user when nested user has no id', async () => {
|
||||||
usersService.createManaged.mockResolvedValue({
|
usersService.createManaged.mockResolvedValue({ id: 'user-9' });
|
||||||
id: 'user-9',
|
|
||||||
username: 'bob',
|
|
||||||
} as Awaited<ReturnType<UsersService['createManaged']>>);
|
|
||||||
repository.create.mockResolvedValue({
|
repository.create.mockResolvedValue({
|
||||||
...sample,
|
...sample,
|
||||||
userId: 'user-9',
|
userId: 'user-9',
|
||||||
@@ -361,10 +372,7 @@ describe('EmployeesService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('create links and updates an existing user by nested id', async () => {
|
it('create links and updates an existing user by nested id', async () => {
|
||||||
usersService.update.mockResolvedValue({
|
usersService.update.mockResolvedValue({ id: 'user-2' });
|
||||||
id: 'user-2',
|
|
||||||
username: 'bobby',
|
|
||||||
} as Awaited<ReturnType<UsersService['update']>>);
|
|
||||||
repository.create.mockResolvedValue({
|
repository.create.mockResolvedValue({
|
||||||
...sample,
|
...sample,
|
||||||
userId: 'user-2',
|
userId: 'user-2',
|
||||||
@@ -405,10 +413,7 @@ describe('EmployeesService', () => {
|
|||||||
userId: 'user-2',
|
userId: 'user-2',
|
||||||
user: { id: 'user-2', username: 'alice' },
|
user: { id: 'user-2', username: 'alice' },
|
||||||
});
|
});
|
||||||
usersService.update.mockResolvedValue({
|
usersService.update.mockResolvedValue({ id: 'user-2' });
|
||||||
id: 'user-2',
|
|
||||||
username: 'alice2',
|
|
||||||
} as Awaited<ReturnType<UsersService['update']>>);
|
|
||||||
repository.update.mockResolvedValue({
|
repository.update.mockResolvedValue({
|
||||||
...sample,
|
...sample,
|
||||||
userId: 'user-2',
|
userId: 'user-2',
|
||||||
@@ -449,9 +454,7 @@ describe('EmployeesService', () => {
|
|||||||
|
|
||||||
it('update rejects nested user assigned to another employee', async () => {
|
it('update rejects nested user assigned to another employee', async () => {
|
||||||
repository.findById.mockResolvedValue(sample);
|
repository.findById.mockResolvedValue(sample);
|
||||||
usersService.findById.mockResolvedValue({
|
usersService.findById.mockResolvedValue({ id: 'user-2' });
|
||||||
id: 'user-2',
|
|
||||||
} as Awaited<ReturnType<UsersService['findById']>>);
|
|
||||||
repository.findByUserId.mockResolvedValue({
|
repository.findByUserId.mockResolvedValue({
|
||||||
...sample,
|
...sample,
|
||||||
id: 'emp-other',
|
id: 'emp-other',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -17,7 +18,6 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
import type {
|
import type {
|
||||||
CreateEmployeeInput,
|
CreateEmployeeInput,
|
||||||
Employee,
|
Employee,
|
||||||
EmployeeUserWrite,
|
|
||||||
UpdateEmployeeInput,
|
UpdateEmployeeInput,
|
||||||
} from './employee';
|
} from './employee';
|
||||||
import {
|
import {
|
||||||
@@ -27,14 +27,14 @@ import {
|
|||||||
parseCsvRecord,
|
parseCsvRecord,
|
||||||
type EmployeePosition,
|
type EmployeePosition,
|
||||||
} from './employee-fields';
|
} from './employee-fields';
|
||||||
import { UsersService } from '../../users/users.service';
|
import { USERS_WRITER, type UsersWriter } from '../../users/users-writer';
|
||||||
import { EmployeesRepository } from './employees.repository';
|
import { EmployeesRepository } from './employees.repository';
|
||||||
|
|
||||||
export type ListEmployeesQuery = {
|
export type ListEmployeesQuery = {
|
||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly phone?: string;
|
readonly phone?: string;
|
||||||
readonly position?: string;
|
readonly position?: readonly string[];
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly userId?: string;
|
readonly userId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
@@ -61,11 +61,17 @@ const VISIBLE_FIELDS = [
|
|||||||
|
|
||||||
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
|
const CSV_REQUIRED_HEADERS = ['code', 'name', 'phone', 'position'] as const;
|
||||||
|
|
||||||
|
interface EmployeeUserWrite {
|
||||||
|
readonly id?: string;
|
||||||
|
readonly username?: string;
|
||||||
|
readonly password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmployeesService {
|
export class EmployeesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly employeesRepository: EmployeesRepository,
|
private readonly employeesRepository: EmployeesRepository,
|
||||||
private readonly usersService: UsersService,
|
@Inject(USERS_WRITER) private readonly usersService: UsersWriter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
|
};
|
||||||
@@ -5,11 +5,17 @@ import { UsersReadController } from './users-read.controller';
|
|||||||
import { UsersWriteController } from './users-write.controller';
|
import { UsersWriteController } from './users-write.controller';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
import { USERS_WRITER } from './users-writer';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrivilegesModule],
|
imports: [PrivilegesModule],
|
||||||
controllers: [UsersReadController, UsersWriteController],
|
controllers: [UsersReadController, UsersWriteController],
|
||||||
providers: [UsersRepository, UsersService, EmployeesRepository],
|
providers: [
|
||||||
exports: [UsersService],
|
UsersRepository,
|
||||||
|
UsersService,
|
||||||
|
EmployeesRepository,
|
||||||
|
{ provide: USERS_WRITER, useExisting: UsersService },
|
||||||
|
],
|
||||||
|
exports: [UsersService, USERS_WRITER],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
isValidUsername,
|
isValidUsername,
|
||||||
parseCsvRecord,
|
parseCsvRecord,
|
||||||
} from './user-fields';
|
} from './user-fields';
|
||||||
|
import type { UsersWriter } from './users-writer';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
|
|
||||||
export type ListUsersQuery = {
|
export type ListUsersQuery = {
|
||||||
@@ -54,7 +55,7 @@ const VISIBLE_FIELDS = [
|
|||||||
const CSV_REQUIRED_HEADERS = ['username', 'password'] as const;
|
const CSV_REQUIRED_HEADERS = ['username', 'password'] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService implements UsersWriter {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly privilegesService: PrivilegesService,
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
|||||||
@@ -202,6 +202,60 @@ describe('Employees (e2e)', () => {
|
|||||||
.expect(204);
|
.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 () => {
|
it('imports employees from CSV', async () => {
|
||||||
const suffix = Date.now().toString().slice(-6);
|
const suffix = Date.now().toString().slice(-6);
|
||||||
const csv =
|
const csv =
|
||||||
|
|||||||
Reference in New Issue
Block a user