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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -9,13 +10,22 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import {
|
import {
|
||||||
PaginationQueryDto,
|
PaginationQueryDto,
|
||||||
UserRelationDto,
|
UserRelationDto,
|
||||||
} from '../../../../common/http/response';
|
} from '../../../../common/http/response';
|
||||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
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 {
|
import {
|
||||||
EMPLOYEE_CODE_MAX_LENGTH,
|
EMPLOYEE_CODE_MAX_LENGTH,
|
||||||
EMPLOYEE_CODE_PATTERN,
|
EMPLOYEE_CODE_PATTERN,
|
||||||
@@ -24,6 +34,37 @@ import {
|
|||||||
EMPLOYEE_POSITIONS,
|
EMPLOYEE_POSITIONS,
|
||||||
} from '../employee-fields';
|
} 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 {
|
export class CreateEmployeeDto {
|
||||||
@ApiProperty({ example: 'EMP_01', maxLength: EMPLOYEE_CODE_MAX_LENGTH })
|
@ApiProperty({ example: 'EMP_01', maxLength: EMPLOYEE_CODE_MAX_LENGTH })
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -64,6 +105,12 @@ export class CreateEmployeeDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: EmployeeUserInputDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => EmployeeUserInputDto)
|
||||||
|
user?: EmployeeUserInputDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateEmployeeDto {
|
export class UpdateEmployeeDto {
|
||||||
@@ -102,6 +149,12 @@ export class UpdateEmployeeDto {
|
|||||||
@ValidateIf((_, value) => value !== undefined)
|
@ValidateIf((_, value) => value !== undefined)
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: EmployeeUserInputDto, nullable: true })
|
||||||
|
@ValidateIf((_, value) => value !== undefined && value !== null)
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => EmployeeUserInputDto)
|
||||||
|
user?: EmployeeUserInputDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateEmployeeStatusDto {
|
export class UpdateEmployeeStatusDto {
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ 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;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('EmployeesWriteController', () => {
|
|||||||
status: undefined,
|
status: undefined,
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
assignedUserId: undefined,
|
assignedUserId: undefined,
|
||||||
|
user: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export class EmployeesWriteController {
|
|||||||
status: dto.status,
|
status: dto.status,
|
||||||
userId,
|
userId,
|
||||||
assignedUserId: dto.userId,
|
assignedUserId: dto.userId,
|
||||||
|
user: dto.user,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +169,7 @@ export class EmployeesWriteController {
|
|||||||
position: dto.position,
|
position: dto.position,
|
||||||
userId,
|
userId,
|
||||||
assignedUserId: dto.userId,
|
assignedUserId: dto.userId,
|
||||||
|
user: dto.user,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ describe('EmployeesRepository', () => {
|
|||||||
expect(employee?.code).toBe('EMP_01');
|
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 () => {
|
it('list returns mapped rows and total', async () => {
|
||||||
select
|
select
|
||||||
.mockImplementationOnce(() => ({
|
.mockImplementationOnce(() => ({
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ export class EmployeesRepository {
|
|||||||
.where(eq(employees.id, id))
|
.where(eq(employees.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
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> {
|
async findByCode(code: string): Promise<Employee | null> {
|
||||||
@@ -76,7 +78,21 @@ export class EmployeesRepository {
|
|||||||
.where(eq(employees.code, code))
|
.where(eq(employees.code, code))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
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> {
|
async create(input: CreateEmployeeInput): Promise<Employee> {
|
||||||
@@ -321,7 +337,10 @@ export class EmployeesRepository {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
private unwrapDbError(error: unknown): { code?: string; constraint?: string } {
|
private unwrapDbError(error: unknown): {
|
||||||
|
code?: string;
|
||||||
|
constraint?: string;
|
||||||
|
} {
|
||||||
let current: unknown = error;
|
let current: unknown = error;
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
if (!current || typeof current !== 'object') {
|
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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
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';
|
||||||
@@ -15,6 +19,7 @@ describe('EmployeesService', () => {
|
|||||||
EmployeesRepository,
|
EmployeesRepository,
|
||||||
| 'list'
|
| 'list'
|
||||||
| 'findById'
|
| 'findById'
|
||||||
|
| 'findByUserId'
|
||||||
| 'create'
|
| 'create'
|
||||||
| 'createMany'
|
| 'createMany'
|
||||||
| 'update'
|
| 'update'
|
||||||
@@ -24,6 +29,9 @@ describe('EmployeesService', () => {
|
|||||||
| 'bulkDelete'
|
| 'bulkDelete'
|
||||||
>
|
>
|
||||||
>;
|
>;
|
||||||
|
let usersService: jest.Mocked<
|
||||||
|
Pick<UsersService, 'findById' | 'createManaged' | '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 = {
|
||||||
@@ -53,6 +61,7 @@ describe('EmployeesService', () => {
|
|||||||
repository = {
|
repository = {
|
||||||
list: jest.fn(),
|
list: jest.fn(),
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -61,15 +70,17 @@ describe('EmployeesService', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
bulkDelete: 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({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
EmployeesService,
|
EmployeesService,
|
||||||
{ provide: EmployeesRepository, useValue: repository },
|
{ provide: EmployeesRepository, useValue: repository },
|
||||||
{
|
{ provide: UsersService, useValue: usersService },
|
||||||
provide: UsersService,
|
|
||||||
useValue: { findById: jest.fn().mockResolvedValue({ id: 'user-2' }) },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -298,4 +309,158 @@ describe('EmployeesService', () => {
|
|||||||
expect.objectContaining({ userId: 'user-1' }),
|
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 {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -11,6 +12,7 @@ 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 {
|
||||||
@@ -108,9 +110,21 @@ export class EmployeesService {
|
|||||||
status?: string;
|
status?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
assignedUserId?: string | null;
|
assignedUserId?: string | null;
|
||||||
|
user?: EmployeeUserWrite;
|
||||||
}): Promise<ReturnType<EmployeesService['toListItem']>> {
|
}): 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(
|
const created = await this.employeesRepository.create(
|
||||||
await this.toCreateInput(input),
|
await this.toCreateInput({ ...input, assignedUserId }),
|
||||||
);
|
);
|
||||||
return this.toListItem(created);
|
return this.toListItem(created);
|
||||||
}
|
}
|
||||||
@@ -125,11 +139,30 @@ export class EmployeesService {
|
|||||||
status?: unknown;
|
status?: unknown;
|
||||||
userId: string;
|
userId: string;
|
||||||
assignedUserId?: string | null;
|
assignedUserId?: string | null;
|
||||||
|
user?: EmployeeUserWrite | null;
|
||||||
},
|
},
|
||||||
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
): Promise<ReturnType<EmployeesService['toListItem']>> {
|
||||||
if (input.status !== undefined) {
|
if (input.status !== undefined) {
|
||||||
throw new BadRequestException('status cannot be updated via PATCH');
|
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 = {
|
const payload: UpdateEmployeeInput = {
|
||||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||||
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||||
@@ -140,7 +173,7 @@ export class EmployeesService {
|
|||||||
? this.assertPosition(input.position)
|
? this.assertPosition(input.position)
|
||||||
: undefined,
|
: undefined,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
assignedUserId: await this.assertAssignedUserId(input.assignedUserId),
|
assignedUserId,
|
||||||
};
|
};
|
||||||
const updated = await this.employeesRepository.update(id, payload);
|
const updated = await this.employeesRepository.update(id, payload);
|
||||||
return this.toListItem(updated);
|
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(
|
private async assertAssignedUserId(
|
||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
): Promise<string | null | undefined> {
|
): Promise<string | null | undefined> {
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export class CreateUserDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CORE_STATUSES])
|
@IsIn([...CORE_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
employeeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@@ -86,6 +91,11 @@ export class UpdateUserDto {
|
|||||||
@ValidateIf((_, value) => value !== undefined)
|
@ValidateIf((_, value) => value !== undefined)
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
privilegeId?: string | null;
|
privilegeId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||||
|
@ValidateIf((_, value) => value !== undefined && value !== null)
|
||||||
|
@IsUUID('4')
|
||||||
|
employeeId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserStatusDto {
|
export class UpdateUserStatusDto {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe('UsersWriteController', () => {
|
|||||||
privilegeId: undefined,
|
privilegeId: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
actorUserId: 'user-1',
|
actorUserId: 'user-1',
|
||||||
|
employeeId: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ export class UsersWriteController {
|
|||||||
privilegeId: dto.privilegeId,
|
privilegeId: dto.privilegeId,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
|
employeeId: dto.employeeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +182,7 @@ export class UsersWriteController {
|
|||||||
password: dto.password,
|
password: dto.password,
|
||||||
privilegeId: dto.privilegeId,
|
privilegeId: dto.privilegeId,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
|
employeeId: dto.employeeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
||||||
import { PrivilegesModule } from '../privileges/privileges.module';
|
import { PrivilegesModule } from '../privileges/privileges.module';
|
||||||
import { UsersReadController } from './users-read.controller';
|
import { UsersReadController } from './users-read.controller';
|
||||||
import { UsersWriteController } from './users-write.controller';
|
import { UsersWriteController } from './users-write.controller';
|
||||||
@@ -8,7 +9,7 @@ import { UsersService } from './users.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrivilegesModule],
|
imports: [PrivilegesModule],
|
||||||
controllers: [UsersReadController, UsersWriteController],
|
controllers: [UsersReadController, UsersWriteController],
|
||||||
providers: [UsersRepository, UsersService],
|
providers: [UsersRepository, UsersService, EmployeesRepository],
|
||||||
exports: [UsersService],
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
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 { Status } from '../../common/value-objects/status/status';
|
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 { PrivilegesService } from '../privileges/privileges.service';
|
||||||
import type { User } from './user';
|
import type { User } from './user';
|
||||||
import { UsersRepository } from './users.repository';
|
import { UsersRepository } from './users.repository';
|
||||||
@@ -32,6 +36,9 @@ describe('UsersService', () => {
|
|||||||
let privilegesService: jest.Mocked<
|
let privilegesService: jest.Mocked<
|
||||||
Pick<PrivilegesService, 'findPrivilegeSummary'>
|
Pick<PrivilegesService, 'findPrivilegeSummary'>
|
||||||
>;
|
>;
|
||||||
|
let employeesRepository: jest.Mocked<
|
||||||
|
Pick<EmployeesRepository, 'findById' | 'findByUserId' | 'update'>
|
||||||
|
>;
|
||||||
|
|
||||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||||
const sampleUser: User = {
|
const sampleUser: User = {
|
||||||
@@ -51,6 +58,21 @@ describe('UsersService', () => {
|
|||||||
updatedByUser: { id: 'user-1', username: 'alice' },
|
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 () => {
|
beforeEach(async () => {
|
||||||
repository = {
|
repository = {
|
||||||
findById: jest.fn(),
|
findById: jest.fn(),
|
||||||
@@ -67,12 +89,18 @@ describe('UsersService', () => {
|
|||||||
privilegesService = {
|
privilegesService = {
|
||||||
findPrivilegeSummary: jest.fn(),
|
findPrivilegeSummary: jest.fn(),
|
||||||
};
|
};
|
||||||
|
employeesRepository = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
findByUserId: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
UsersService,
|
UsersService,
|
||||||
{ provide: UsersRepository, useValue: repository },
|
{ provide: UsersRepository, useValue: repository },
|
||||||
{ provide: PrivilegesService, useValue: privilegesService },
|
{ provide: PrivilegesService, useValue: privilegesService },
|
||||||
|
{ provide: EmployeesRepository, useValue: employeesRepository },
|
||||||
{
|
{
|
||||||
provide: ConfigService,
|
provide: ConfigService,
|
||||||
useValue: { getOrThrow: () => 4 },
|
useValue: { getOrThrow: () => 4 },
|
||||||
@@ -187,8 +215,149 @@ describe('UsersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('importCsv requires username and password headers', async () => {
|
it('importCsv requires username and password headers', async () => {
|
||||||
await expect(service.importCsv('name\nalice', 'user-1')).rejects.toBeInstanceOf(
|
await expect(
|
||||||
BadRequestException,
|
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';
|
} from '../../common/http/response';
|
||||||
import { InvalidStatusError } from '../../common/value-objects/status/invalid-status.error';
|
import { InvalidStatusError } from '../../common/value-objects/status/invalid-status.error';
|
||||||
import { Status } from '../../common/value-objects/status/status';
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
|
import { EmployeesRepository } from '../configuration/employees/employees.repository';
|
||||||
import { PrivilegesService } from '../privileges/privileges.service';
|
import { PrivilegesService } from '../privileges/privileges.service';
|
||||||
import type { CreateUserInput, UpdateUserInput, User } from './user';
|
import type { CreateUserInput, UpdateUserInput, User } from './user';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +56,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
private readonly privilegesService: PrivilegesService,
|
private readonly privilegesService: PrivilegesService,
|
||||||
|
private readonly employeesRepository: EmployeesRepository,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -80,9 +82,7 @@ export class UsersService {
|
|||||||
return this.usersRepository.findById(id);
|
return this.usersRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(
|
async getById(id: string): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
id: string,
|
|
||||||
): Promise<ReturnType<UsersService['toListItem']>> {
|
|
||||||
const user = await this.usersRepository.findById(id);
|
const user = await this.usersRepository.findById(id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
@@ -112,12 +112,26 @@ export class UsersService {
|
|||||||
privilegeId?: string;
|
privilegeId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
employeeId?: string;
|
||||||
}): Promise<ReturnType<UsersService['toListItem']>> {
|
}): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
const created = await this.usersRepository.create(
|
const created = await this.usersRepository.create(
|
||||||
await this.toCreateInput(input),
|
await this.toCreateInput(input),
|
||||||
);
|
);
|
||||||
|
if (!input.employeeId) {
|
||||||
return this.toListItem(created);
|
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(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -127,6 +141,7 @@ export class UsersService {
|
|||||||
privilegeId?: string | null;
|
privilegeId?: string | null;
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
employeeId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<ReturnType<UsersService['toListItem']>> {
|
): Promise<ReturnType<UsersService['toListItem']>> {
|
||||||
if (input.status !== undefined) {
|
if (input.status !== undefined) {
|
||||||
@@ -147,7 +162,17 @@ export class UsersService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
actorUserId: input.actorUserId,
|
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);
|
const updated = await this.usersRepository.update(id, payload);
|
||||||
|
if (input.employeeId !== undefined) {
|
||||||
|
return this.getById(id);
|
||||||
|
}
|
||||||
return this.toListItem(updated);
|
return this.toListItem(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +321,42 @@ export class UsersService {
|
|||||||
return VISIBLE_FIELDS;
|
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: {
|
private async toCreateInput(input: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -316,7 +377,9 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
username: this.assertUsername(input.username),
|
username: this.assertUsername(input.username),
|
||||||
passwordHash: await this.hashPassword(this.assertPassword(input.password)),
|
passwordHash: await this.hashPassword(
|
||||||
|
this.assertPassword(input.password),
|
||||||
|
),
|
||||||
privilegeId:
|
privilegeId:
|
||||||
input.privilegeId !== undefined && input.privilegeId !== ''
|
input.privilegeId !== undefined && input.privilegeId !== ''
|
||||||
? await this.assertPrivilegeId(input.privilegeId)
|
? await this.assertPrivilegeId(input.privilegeId)
|
||||||
|
|||||||
@@ -215,4 +215,67 @@ describe('Employees (e2e)', () => {
|
|||||||
|
|
||||||
expect(res.body).toMatchObject({ imported: 1 });
|
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] })
|
.send({ ids: [id] })
|
||||||
.expect(200);
|
.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