Enhance employee and user management with linked user functionality
- Introduced `EmployeeUserWrite` type to manage user details associated with employees. - Updated `EmployeesService` and `EmployeesRepository` to support user assignment and retrieval by user ID. - Enhanced DTOs to include user information for employee creation and updates. - Implemented validation to ensure proper handling of user data during employee operations. - Added unit and e2e tests to validate the new functionality and ensure data integrity in user-employee relationships. - Modified existing controllers to accommodate the new user linkage features in employee management.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
@@ -9,13 +10,22 @@ import {
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
USERNAME_MAX_LENGTH,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_PATTERN,
|
||||
} from '../../../users/user-fields';
|
||||
import {
|
||||
EMPLOYEE_CODE_MAX_LENGTH,
|
||||
EMPLOYEE_CODE_PATTERN,
|
||||
@@ -24,6 +34,37 @@ import {
|
||||
EMPLOYEE_POSITIONS,
|
||||
} from '../employee-fields';
|
||||
|
||||
export class EmployeeUserInputDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'alice' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(USERNAME_MIN_LENGTH)
|
||||
@MaxLength(USERNAME_MAX_LENGTH)
|
||||
@Matches(USERNAME_PATTERN, {
|
||||
message: 'username must contain only letters, numbers, and underscores',
|
||||
})
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'password123',
|
||||
format: 'password',
|
||||
writeOnly: true,
|
||||
description:
|
||||
'Required when creating a new user. Ignored/rejected when linking an existing user.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(PASSWORD_MIN_LENGTH)
|
||||
@MaxLength(PASSWORD_MAX_LENGTH)
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class CreateEmployeeDto {
|
||||
@ApiProperty({ example: 'EMP_01', maxLength: EMPLOYEE_CODE_MAX_LENGTH })
|
||||
@IsString()
|
||||
@@ -64,6 +105,12 @@ export class CreateEmployeeDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: EmployeeUserInputDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmployeeUserInputDto)
|
||||
user?: EmployeeUserInputDto;
|
||||
}
|
||||
|
||||
export class UpdateEmployeeDto {
|
||||
@@ -102,6 +149,12 @@ export class UpdateEmployeeDto {
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsUUID('4')
|
||||
userId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: EmployeeUserInputDto, nullable: true })
|
||||
@ValidateIf((_, value) => value !== undefined && value !== null)
|
||||
@ValidateNested()
|
||||
@Type(() => EmployeeUserInputDto)
|
||||
user?: EmployeeUserInputDto | null;
|
||||
}
|
||||
|
||||
export class UpdateEmployeeStatusDto {
|
||||
|
||||
@@ -37,6 +37,12 @@ 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;
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('EmployeesWriteController', () => {
|
||||
status: undefined,
|
||||
userId: 'user-1',
|
||||
assignedUserId: undefined,
|
||||
user: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export class EmployeesWriteController {
|
||||
status: dto.status,
|
||||
userId,
|
||||
assignedUserId: dto.userId,
|
||||
user: dto.user,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +169,7 @@ export class EmployeesWriteController {
|
||||
position: dto.position,
|
||||
userId,
|
||||
assignedUserId: dto.userId,
|
||||
user: dto.user,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ describe('EmployeesRepository', () => {
|
||||
expect(employee?.code).toBe('EMP_01');
|
||||
});
|
||||
|
||||
it('findByUserId maps a row', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const employee = await repository.findByUserId('user-1');
|
||||
expect(employee?.id).toBe('emp-1');
|
||||
});
|
||||
|
||||
it('list returns mapped rows and total', async () => {
|
||||
select
|
||||
.mockImplementationOnce(() => ({
|
||||
|
||||
@@ -66,7 +66,9 @@ export class EmployeesRepository {
|
||||
.where(eq(employees.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null;
|
||||
return row
|
||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Employee | null> {
|
||||
@@ -76,7 +78,21 @@ export class EmployeesRepository {
|
||||
.where(eq(employees.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row, await this.loadAssignedUser(row.userId)) : null;
|
||||
return row
|
||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<Employee | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(employees)
|
||||
.where(eq(employees.userId, userId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row
|
||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
||||
: null;
|
||||
}
|
||||
|
||||
async create(input: CreateEmployeeInput): Promise<Employee> {
|
||||
@@ -321,7 +337,10 @@ export class EmployeesRepository {
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string; constraint?: string } {
|
||||
private unwrapDbError(error: unknown): {
|
||||
code?: string;
|
||||
constraint?: string;
|
||||
} {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||
@@ -15,6 +19,7 @@ describe('EmployeesService', () => {
|
||||
EmployeesRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'findByUserId'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
@@ -24,6 +29,9 @@ describe('EmployeesService', () => {
|
||||
| 'bulkDelete'
|
||||
>
|
||||
>;
|
||||
let usersService: jest.Mocked<
|
||||
Pick<UsersService, 'findById' | 'createManaged' | 'update'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: Employee = {
|
||||
@@ -53,6 +61,7 @@ describe('EmployeesService', () => {
|
||||
repository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
@@ -61,15 +70,17 @@ describe('EmployeesService', () => {
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
usersService = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'user-2' }),
|
||||
createManaged: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EmployeesService,
|
||||
{ provide: EmployeesRepository, useValue: repository },
|
||||
{
|
||||
provide: UsersService,
|
||||
useValue: { findById: jest.fn().mockResolvedValue({ id: 'user-2' }) },
|
||||
},
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -298,4 +309,158 @@ describe('EmployeesService', () => {
|
||||
expect.objectContaining({ userId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
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']>>);
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-9',
|
||||
user: { id: 'user-9', username: 'bob' },
|
||||
});
|
||||
|
||||
const result = await service.create({
|
||||
...createInput,
|
||||
user: { username: 'bob', password: 'password123' },
|
||||
});
|
||||
|
||||
expect(usersService.createManaged).toHaveBeenCalledWith({
|
||||
username: 'bob',
|
||||
password: 'password123',
|
||||
actorUserId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedUserId: 'user-9' }),
|
||||
);
|
||||
expect(result.user).toEqual({ id: 'user-9', username: 'bob' });
|
||||
});
|
||||
|
||||
it('create rejects password when linking an existing user', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
user: { id: 'user-2', password: 'password123' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('create rejects nested user without username and password', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
user: { username: 'bob' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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']>>);
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'bobby' },
|
||||
});
|
||||
|
||||
await service.create({
|
||||
...createInput,
|
||||
user: { id: 'user-2', username: 'bobby' },
|
||||
});
|
||||
|
||||
expect(usersService.createManaged).not.toHaveBeenCalled();
|
||||
expect(usersService.update).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
expect.objectContaining({
|
||||
username: 'bobby',
|
||||
actorUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedUserId: 'user-2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create rejects userId that differs from nested user.id', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...createInput,
|
||||
assignedUserId: 'user-2',
|
||||
user: { id: 'user-3', username: 'bob', password: 'password123' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update writes nested user onto the currently linked account', async () => {
|
||||
repository.findById.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'alice' },
|
||||
});
|
||||
usersService.update.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
username: 'alice2',
|
||||
} as Awaited<ReturnType<UsersService['update']>>);
|
||||
repository.update.mockResolvedValue({
|
||||
...sample,
|
||||
userId: 'user-2',
|
||||
user: { id: 'user-2', username: 'alice2' },
|
||||
});
|
||||
|
||||
const result = await service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: { username: 'alice2' },
|
||||
});
|
||||
|
||||
expect(usersService.update).toHaveBeenCalledWith(
|
||||
'user-2',
|
||||
expect.objectContaining({
|
||||
username: 'alice2',
|
||||
actorUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
expect(result.user?.username).toBe('alice2');
|
||||
});
|
||||
|
||||
it('update with user null unlinks the assigned user', async () => {
|
||||
repository.update.mockResolvedValue({
|
||||
...sample,
|
||||
userId: null,
|
||||
user: null,
|
||||
});
|
||||
const result = await service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: null,
|
||||
});
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({ assignedUserId: null }),
|
||||
);
|
||||
expect(result.user).toBeNull();
|
||||
});
|
||||
|
||||
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']>>);
|
||||
repository.findByUserId.mockResolvedValue({
|
||||
...sample,
|
||||
id: 'emp-other',
|
||||
userId: 'user-2',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update('emp-1', {
|
||||
userId: 'user-1',
|
||||
user: { id: 'user-2' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -11,6 +12,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
import type {
|
||||
CreateEmployeeInput,
|
||||
Employee,
|
||||
EmployeeUserWrite,
|
||||
UpdateEmployeeInput,
|
||||
} from './employee';
|
||||
import {
|
||||
@@ -108,9 +110,21 @@ export class EmployeesService {
|
||||
status?: string;
|
||||
userId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite;
|
||||
}): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
this.assertCode(input.code);
|
||||
this.assertName(input.name);
|
||||
this.assertPhone(input.phone);
|
||||
this.assertPosition(input.position);
|
||||
const assignedUserId = await this.resolveAssignedUser({
|
||||
actorUserId: input.userId,
|
||||
assignedUserId: input.assignedUserId,
|
||||
user: input.user,
|
||||
currentUserId: null,
|
||||
currentEmployeeId: null,
|
||||
});
|
||||
const created = await this.employeesRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
await this.toCreateInput({ ...input, assignedUserId }),
|
||||
);
|
||||
return this.toListItem(created);
|
||||
}
|
||||
@@ -125,11 +139,30 @@ export class EmployeesService {
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite | null;
|
||||
},
|
||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
let assignedUserId = await this.assertAssignedUserId(input.assignedUserId);
|
||||
if (input.user !== undefined) {
|
||||
if (input.user === null) {
|
||||
assignedUserId = null;
|
||||
} else {
|
||||
const current = await this.employeesRepository.findById(id);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
assignedUserId = await this.resolveAssignedUser({
|
||||
actorUserId: input.userId,
|
||||
assignedUserId: input.assignedUserId,
|
||||
user: input.user,
|
||||
currentUserId: current.userId,
|
||||
currentEmployeeId: current.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const payload: UpdateEmployeeInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||
@@ -140,7 +173,7 @@ export class EmployeesService {
|
||||
? this.assertPosition(input.position)
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
|
||||
assignedUserId,
|
||||
};
|
||||
const updated = await this.employeesRepository.update(id, payload);
|
||||
return this.toListItem(updated);
|
||||
@@ -283,6 +316,60 @@ export class EmployeesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveAssignedUser(input: {
|
||||
actorUserId: string;
|
||||
assignedUserId?: string | null;
|
||||
user?: EmployeeUserWrite;
|
||||
currentUserId: string | null;
|
||||
currentEmployeeId: string | null;
|
||||
}): Promise<string | null | undefined> {
|
||||
if (!input.user) {
|
||||
return this.assertAssignedUserId(input.assignedUserId);
|
||||
}
|
||||
if (
|
||||
input.user.id &&
|
||||
input.assignedUserId &&
|
||||
input.user.id !== input.assignedUserId
|
||||
) {
|
||||
throw new BadRequestException('userId and user.id must match');
|
||||
}
|
||||
|
||||
const targetId =
|
||||
input.user.id ?? input.assignedUserId ?? input.currentUserId ?? undefined;
|
||||
|
||||
if (targetId) {
|
||||
if (input.user.password !== undefined) {
|
||||
throw new BadRequestException(
|
||||
'password can only be set when creating a user',
|
||||
);
|
||||
}
|
||||
await this.assertAssignedUserId(targetId);
|
||||
const taken = await this.employeesRepository.findByUserId(targetId);
|
||||
if (taken && taken.id !== input.currentEmployeeId) {
|
||||
throw new ConflictException('User is already assigned to an employee');
|
||||
}
|
||||
if (input.user.username !== undefined) {
|
||||
await this.usersService.update(targetId, {
|
||||
username: input.user.username,
|
||||
actorUserId: input.actorUserId,
|
||||
});
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
if (!input.user.username || !input.user.password) {
|
||||
throw new BadRequestException(
|
||||
'username and password are required to create a user',
|
||||
);
|
||||
}
|
||||
const created = await this.usersService.createManaged({
|
||||
username: input.user.username,
|
||||
password: input.user.password,
|
||||
actorUserId: input.actorUserId,
|
||||
});
|
||||
return created.id;
|
||||
}
|
||||
|
||||
private async assertAssignedUserId(
|
||||
userId?: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
|
||||
@@ -57,6 +57,11 @@ export class CreateUserDto {
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
employeeId?: string;
|
||||
}
|
||||
|
||||
export class UpdateUserDto {
|
||||
@@ -86,6 +91,11 @@ export class UpdateUserDto {
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsUUID('4')
|
||||
privilegeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@ValidateIf((_, value) => value !== undefined && value !== null)
|
||||
@IsUUID('4')
|
||||
employeeId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateUserStatusDto {
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('UsersWriteController', () => {
|
||||
privilegeId: undefined,
|
||||
status: undefined,
|
||||
actorUserId: 'user-1',
|
||||
employeeId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ export class UsersWriteController {
|
||||
privilegeId: dto.privilegeId,
|
||||
status: dto.status,
|
||||
actorUserId: userId,
|
||||
employeeId: dto.employeeId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,6 +182,7 @@ export class UsersWriteController {
|
||||
password: dto.password,
|
||||
privilegeId: dto.privilegeId,
|
||||
actorUserId: userId,
|
||||
employeeId: dto.employeeId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||
import { UsersReadController } from './users-read.controller';
|
||||
import { UsersWriteController } from './users-write.controller';
|
||||
@@ -8,7 +9,7 @@ import { UsersService } from './users.service';
|
||||
@Module({
|
||||
imports: [PrivilegesModule],
|
||||
controllers: [UsersReadController, UsersWriteController],
|
||||
providers: [UsersRepository, UsersService],
|
||||
providers: [UsersRepository, UsersService, EmployeesRepository],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
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 '../configuration/employees/employee';
|
||||
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import type { User } from './user';
|
||||
import { UsersRepository } from './users.repository';
|
||||
@@ -32,6 +36,9 @@ describe('UsersService', () => {
|
||||
let privilegesService: jest.Mocked<
|
||||
Pick<PrivilegesService, 'findPrivilegeSummary'>
|
||||
>;
|
||||
let employeesRepository: jest.Mocked<
|
||||
Pick<EmployeesRepository, 'findById' | 'findByUserId' | 'update'>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sampleUser: User = {
|
||||
@@ -51,6 +58,21 @@ describe('UsersService', () => {
|
||||
updatedByUser: { id: 'user-1', username: 'alice' },
|
||||
};
|
||||
|
||||
const sampleEmployee: Employee = {
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
phone: PhoneNumber.create('+6281234567890'),
|
||||
position: 'sales',
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'actor-1',
|
||||
updatedBy: 'actor-1',
|
||||
userId: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
findById: jest.fn(),
|
||||
@@ -67,12 +89,18 @@ describe('UsersService', () => {
|
||||
privilegesService = {
|
||||
findPrivilegeSummary: jest.fn(),
|
||||
};
|
||||
employeesRepository = {
|
||||
findById: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{ provide: UsersRepository, useValue: repository },
|
||||
{ provide: PrivilegesService, useValue: privilegesService },
|
||||
{ provide: EmployeesRepository, useValue: employeesRepository },
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: { getOrThrow: () => 4 },
|
||||
@@ -187,8 +215,149 @@ describe('UsersService', () => {
|
||||
});
|
||||
|
||||
it('importCsv requires username and password headers', async () => {
|
||||
await expect(service.importCsv('name\nalice', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
await expect(
|
||||
service.importCsv('name\nalice', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('createManaged links an existing employee by id', async () => {
|
||||
repository.create.mockResolvedValue(sampleUser);
|
||||
employeesRepository.findById.mockResolvedValue(sampleEmployee);
|
||||
employeesRepository.update.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
userId: 'user-1',
|
||||
});
|
||||
repository.findById.mockResolvedValue({
|
||||
...sampleUser,
|
||||
employee: {
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
status: Status.create('draft'),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.createManaged({
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
actorUserId: 'actor-1',
|
||||
employeeId: 'emp-1',
|
||||
});
|
||||
|
||||
expect(employeesRepository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({
|
||||
userId: 'actor-1',
|
||||
assignedUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
expect(result.employee).toEqual({
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
});
|
||||
});
|
||||
|
||||
it('createManaged rejects linking an employee assigned to another user', async () => {
|
||||
repository.create.mockResolvedValue(sampleUser);
|
||||
employeesRepository.findById.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
userId: 'other-user',
|
||||
});
|
||||
repository.delete.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.createManaged({
|
||||
username: 'alice',
|
||||
password: 'password123',
|
||||
actorUserId: 'actor-1',
|
||||
employeeId: 'emp-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(repository.delete).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('update with employeeId null unlinks the current employee', async () => {
|
||||
repository.update.mockResolvedValue({
|
||||
...sampleUser,
|
||||
employee: {
|
||||
id: 'emp-1',
|
||||
code: 'EMP_01',
|
||||
name: 'Ada Lovelace',
|
||||
status: Status.create('draft'),
|
||||
},
|
||||
});
|
||||
employeesRepository.findByUserId.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
userId: 'user-1',
|
||||
});
|
||||
employeesRepository.update.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
userId: null,
|
||||
});
|
||||
repository.findById.mockResolvedValue({ ...sampleUser, employee: null });
|
||||
|
||||
const result = await service.update('user-1', {
|
||||
actorUserId: 'actor-1',
|
||||
employeeId: null,
|
||||
});
|
||||
|
||||
expect(employeesRepository.update).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
expect.objectContaining({ assignedUserId: null, userId: 'actor-1' }),
|
||||
);
|
||||
expect(result.employee).toBeNull();
|
||||
});
|
||||
|
||||
it('update with employeeId reassigns after unlinking the previous employee', async () => {
|
||||
repository.update.mockResolvedValue(sampleUser);
|
||||
employeesRepository.findById.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
id: 'emp-2',
|
||||
code: 'EMP_02',
|
||||
});
|
||||
employeesRepository.findByUserId.mockResolvedValue({
|
||||
...sampleEmployee,
|
||||
userId: 'user-1',
|
||||
});
|
||||
employeesRepository.update.mockResolvedValue(sampleEmployee);
|
||||
repository.findById.mockResolvedValue({
|
||||
...sampleUser,
|
||||
employee: {
|
||||
id: 'emp-2',
|
||||
code: 'EMP_02',
|
||||
name: 'Ada Lovelace',
|
||||
status: Status.create('draft'),
|
||||
},
|
||||
});
|
||||
|
||||
await service.update('user-1', {
|
||||
actorUserId: 'actor-1',
|
||||
employeeId: 'emp-2',
|
||||
});
|
||||
|
||||
expect(employeesRepository.update).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'emp-1',
|
||||
expect.objectContaining({ assignedUserId: null }),
|
||||
);
|
||||
expect(employeesRepository.update).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'emp-2',
|
||||
expect.objectContaining({ assignedUserId: 'user-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('update with unknown employeeId throws NotFoundException', async () => {
|
||||
repository.findById.mockResolvedValue(sampleUser);
|
||||
employeesRepository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('user-1', {
|
||||
actorUserId: 'actor-1',
|
||||
employeeId: 'missing',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../common/http/response';
|
||||
import { InvalidStatusError } from '../../common/value-objects/status/invalid-status.error';
|
||||
import { Status } from '../../common/value-objects/status/status';
|
||||
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
||||
import { PrivilegesService } from '../privileges/privileges.service';
|
||||
import type { CreateUserInput, UpdateUserInput, User } from './user';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export class UsersService {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly privilegesService: PrivilegesService,
|
||||
private readonly employeesRepository: EmployeesRepository,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -80,9 +82,7 @@ export class UsersService {
|
||||
return this.usersRepository.findById(id);
|
||||
}
|
||||
|
||||
async getById(
|
||||
id: string,
|
||||
): Promise<ReturnType<UsersService['toListItem']>> {
|
||||
async getById(id: string): Promise<ReturnType<UsersService['toListItem']>> {
|
||||
const user = await this.usersRepository.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -112,11 +112,25 @@ export class UsersService {
|
||||
privilegeId?: string;
|
||||
status?: string;
|
||||
actorUserId: string;
|
||||
employeeId?: string;
|
||||
}): Promise<ReturnType<UsersService['toListItem']>> {
|
||||
const created = await this.usersRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
return this.toListItem(created);
|
||||
if (!input.employeeId) {
|
||||
return this.toListItem(created);
|
||||
}
|
||||
try {
|
||||
await this.syncEmployee(created.id, input.employeeId, input.actorUserId);
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.usersRepository.delete(created.id);
|
||||
} catch {
|
||||
// Preserve the original sync error.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getById(created.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -127,6 +141,7 @@ export class UsersService {
|
||||
privilegeId?: string | null;
|
||||
status?: unknown;
|
||||
actorUserId: string;
|
||||
employeeId?: string | null;
|
||||
},
|
||||
): Promise<ReturnType<UsersService['toListItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
@@ -147,7 +162,17 @@ export class UsersService {
|
||||
: undefined,
|
||||
actorUserId: input.actorUserId,
|
||||
};
|
||||
if (input.employeeId !== undefined) {
|
||||
const existing = await this.usersRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
await this.syncEmployee(id, input.employeeId, input.actorUserId);
|
||||
}
|
||||
const updated = await this.usersRepository.update(id, payload);
|
||||
if (input.employeeId !== undefined) {
|
||||
return this.getById(id);
|
||||
}
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
@@ -296,6 +321,42 @@ export class UsersService {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
private async syncEmployee(
|
||||
userId: string,
|
||||
employeeId: string | null,
|
||||
actorUserId: string,
|
||||
): Promise<void> {
|
||||
if (employeeId === null) {
|
||||
const linked = await this.employeesRepository.findByUserId(userId);
|
||||
if (linked) {
|
||||
await this.employeesRepository.update(linked.id, {
|
||||
userId: actorUserId,
|
||||
assignedUserId: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await this.employeesRepository.findById(employeeId);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Employee not found');
|
||||
}
|
||||
if (existing.userId && existing.userId !== userId) {
|
||||
throw new ConflictException('Employee is already assigned to a user');
|
||||
}
|
||||
const current = await this.employeesRepository.findByUserId(userId);
|
||||
if (current && current.id !== employeeId) {
|
||||
await this.employeesRepository.update(current.id, {
|
||||
userId: actorUserId,
|
||||
assignedUserId: null,
|
||||
});
|
||||
}
|
||||
await this.employeesRepository.update(employeeId, {
|
||||
userId: actorUserId,
|
||||
assignedUserId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -316,7 +377,9 @@ export class UsersService {
|
||||
}
|
||||
return {
|
||||
username: this.assertUsername(input.username),
|
||||
passwordHash: await this.hashPassword(this.assertPassword(input.password)),
|
||||
passwordHash: await this.hashPassword(
|
||||
this.assertPassword(input.password),
|
||||
),
|
||||
privilegeId:
|
||||
input.privilegeId !== undefined && input.privilegeId !== ''
|
||||
? await this.assertPrivilegeId(input.privilegeId)
|
||||
|
||||
@@ -215,4 +215,67 @@ describe('Employees (e2e)', () => {
|
||||
|
||||
expect(res.body).toMatchObject({ imported: 1 });
|
||||
});
|
||||
|
||||
it('creates and updates a linked user from the employee payload', async () => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/employees')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
code: `EU_${suffix}`,
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
user: {
|
||||
username: `emp_usr_${suffix}`,
|
||||
password: 'password123',
|
||||
},
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body.user).toMatchObject({
|
||||
username: `emp_usr_${suffix}`,
|
||||
});
|
||||
const employeeId = (created.body as { id: string }).id;
|
||||
const linkedUserId = (created.body.user as { id: string }).id;
|
||||
|
||||
const user = await request(app.getHttpServer())
|
||||
.get(`/users/${linkedUserId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
expect(user.body.employee).toMatchObject({
|
||||
id: employeeId,
|
||||
code: `EU_${suffix}`,
|
||||
});
|
||||
|
||||
const renamed = await request(app.getHttpServer())
|
||||
.patch(`/employees/${employeeId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({ user: { username: `emp_ren_${suffix}` } })
|
||||
.expect(200);
|
||||
expect(renamed.body.user).toMatchObject({
|
||||
id: linkedUserId,
|
||||
username: `emp_ren_${suffix}`,
|
||||
});
|
||||
|
||||
const existingUser = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
username: `emp_ex_${suffix}`,
|
||||
password: 'password123',
|
||||
})
|
||||
.expect(201);
|
||||
const existingUserId = (existingUser.body as { id: string }).id;
|
||||
|
||||
const assigned = await request(app.getHttpServer())
|
||||
.patch(`/employees/${employeeId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({ user: { id: existingUserId, username: `emp_as_${suffix}` } })
|
||||
.expect(200);
|
||||
expect(assigned.body.user).toMatchObject({
|
||||
id: existingUserId,
|
||||
username: `emp_as_${suffix}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,4 +155,74 @@ describe('Users (e2e)', () => {
|
||||
.send({ ids: [id] })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('creates and updates a linked employee from the user payload', async () => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const employee = await request(app.getHttpServer())
|
||||
.post('/employees')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
code: `UE_${suffix}`,
|
||||
name: 'Ada Lovelace',
|
||||
phone: '+6281234567890',
|
||||
position: 'sales',
|
||||
})
|
||||
.expect(201);
|
||||
const employeeId = (employee.body as { id: string }).id;
|
||||
|
||||
const created = await request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
username: `usr_emp_${suffix}`,
|
||||
password: 'password123',
|
||||
employeeId,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(created.body.employee).toMatchObject({
|
||||
id: employeeId,
|
||||
code: `UE_${suffix}`,
|
||||
name: 'Ada Lovelace',
|
||||
});
|
||||
const userId = (created.body as { id: string }).id;
|
||||
|
||||
const linked = await request(app.getHttpServer())
|
||||
.get(`/employees/${employeeId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.expect(200);
|
||||
expect(linked.body.user).toMatchObject({
|
||||
id: userId,
|
||||
username: `usr_emp_${suffix}`,
|
||||
});
|
||||
|
||||
const existing = await request(app.getHttpServer())
|
||||
.post('/employees')
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({
|
||||
code: `UL_${suffix}`,
|
||||
name: 'Grace Hopper',
|
||||
phone: '+6281234567891',
|
||||
position: 'crew',
|
||||
})
|
||||
.expect(201);
|
||||
const existingId = (existing.body as { id: string }).id;
|
||||
|
||||
const reassigned = await request(app.getHttpServer())
|
||||
.patch(`/users/${userId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({ employeeId: existingId })
|
||||
.expect(200);
|
||||
expect(reassigned.body.employee).toMatchObject({
|
||||
id: existingId,
|
||||
name: 'Grace Hopper',
|
||||
});
|
||||
|
||||
const unlinked = await request(app.getHttpServer())
|
||||
.patch(`/users/${userId}`)
|
||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||
.send({ employeeId: null })
|
||||
.expect(200);
|
||||
expect(unlinked.body.employee).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user