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:
shancheas
2026-08-27 12:22:25 +07:00
parent 8a61c94078
commit 790725e227
16 changed files with 736 additions and 18 deletions
@@ -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> {
+10
View File
@@ -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,
});
}
+2 -1
View File
@@ -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 {}
+171 -2
View File
@@ -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();
});
});
+67 -4
View File
@@ -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,12 +112,26 @@ 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),
);
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(
id: string,
@@ -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)
+63
View File
@@ -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}`,
});
});
});
+70
View File
@@ -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();
});
});