diff --git a/src/modules/configuration/employees/dto/employee.dto.ts b/src/modules/configuration/employees/dto/employee.dto.ts index 2fcf0cd..42ab150 100644 --- a/src/modules/configuration/employees/dto/employee.dto.ts +++ b/src/modules/configuration/employees/dto/employee.dto.ts @@ -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 { diff --git a/src/modules/configuration/employees/employee.ts b/src/modules/configuration/employees/employee.ts index 946c910..89b9652 100644 --- a/src/modules/configuration/employees/employee.ts +++ b/src/modules/configuration/employees/employee.ts @@ -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; diff --git a/src/modules/configuration/employees/employees-write.controller.spec.ts b/src/modules/configuration/employees/employees-write.controller.spec.ts index 7afcbf1..5744cd6 100644 --- a/src/modules/configuration/employees/employees-write.controller.spec.ts +++ b/src/modules/configuration/employees/employees-write.controller.spec.ts @@ -38,6 +38,7 @@ describe('EmployeesWriteController', () => { status: undefined, userId: 'user-1', assignedUserId: undefined, + user: undefined, }); }); diff --git a/src/modules/configuration/employees/employees-write.controller.ts b/src/modules/configuration/employees/employees-write.controller.ts index de6b6cf..6cf431c 100644 --- a/src/modules/configuration/employees/employees-write.controller.ts +++ b/src/modules/configuration/employees/employees-write.controller.ts @@ -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, }); } diff --git a/src/modules/configuration/employees/employees.repository.spec.ts b/src/modules/configuration/employees/employees.repository.spec.ts index 903860c..45cc1c8 100644 --- a/src/modules/configuration/employees/employees.repository.spec.ts +++ b/src/modules/configuration/employees/employees.repository.spec.ts @@ -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(() => ({ diff --git a/src/modules/configuration/employees/employees.repository.ts b/src/modules/configuration/employees/employees.repository.ts index 99ccf88..3dd9010 100644 --- a/src/modules/configuration/employees/employees.repository.ts +++ b/src/modules/configuration/employees/employees.repository.ts @@ -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 { @@ -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 { + 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 { @@ -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') { diff --git a/src/modules/configuration/employees/employees.service.spec.ts b/src/modules/configuration/employees/employees.service.spec.ts index 735df39..f3f30dc 100644 --- a/src/modules/configuration/employees/employees.service.spec.ts +++ b/src/modules/configuration/employees/employees.service.spec.ts @@ -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 + >; 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>); + 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>); + 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>); + 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>); + 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); + }); }); diff --git a/src/modules/configuration/employees/employees.service.ts b/src/modules/configuration/employees/employees.service.ts index c1a3990..9da5186 100644 --- a/src/modules/configuration/employees/employees.service.ts +++ b/src/modules/configuration/employees/employees.service.ts @@ -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> { + 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> { 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 { + 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 { diff --git a/src/modules/users/dto/user.dto.ts b/src/modules/users/dto/user.dto.ts index 845c5b0..ebc03fd 100644 --- a/src/modules/users/dto/user.dto.ts +++ b/src/modules/users/dto/user.dto.ts @@ -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 { diff --git a/src/modules/users/users-write.controller.spec.ts b/src/modules/users/users-write.controller.spec.ts index ec5da50..c17f40a 100644 --- a/src/modules/users/users-write.controller.spec.ts +++ b/src/modules/users/users-write.controller.spec.ts @@ -38,6 +38,7 @@ describe('UsersWriteController', () => { privilegeId: undefined, status: undefined, actorUserId: 'user-1', + employeeId: undefined, }); }); diff --git a/src/modules/users/users-write.controller.ts b/src/modules/users/users-write.controller.ts index a49a7a0..d15b6fa 100644 --- a/src/modules/users/users-write.controller.ts +++ b/src/modules/users/users-write.controller.ts @@ -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, }); } diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index cca925a..f338868 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -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 {} diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index c3277e6..ab2f501 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -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 >; + let employeesRepository: jest.Mocked< + Pick + >; 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(); + }); }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index f1f2522..4f4baaf 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -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> { + async getById(id: string): Promise> { 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> { 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> { 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 { + 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) diff --git a/test/employees.e2e-spec.ts b/test/employees.e2e-spec.ts index 7a2f4b7..d820ed2 100644 --- a/test/employees.e2e-spec.ts +++ b/test/employees.e2e-spec.ts @@ -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}`, + }); + }); }); diff --git a/test/users.e2e-spec.ts b/test/users.e2e-spec.ts index 6a2cf53..26f8899 100644 --- a/test/users.e2e-spec.ts +++ b/test/users.e2e-spec.ts @@ -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(); + }); });