import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService, type DatabaseQueryClient } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { AuditService } from '../../audit/audit.service';
import {
assertEmployeeCreateInput,
assertEmployeeReferenceInput,
assertEmployeeUpdateInput,
} from './employee-input-validation';
@Injectable()
export class EmployeesService {
constructor(
private readonly database: DatabaseService,
private readonly audit: AuditService,
) {}
async create(input: {
organizationId: string;
firstName: string;
lastName: string;
displayName?: string;
employeeNumber?: string;
preferredLocale?: string;
}) {
assertEmployeeCreateInput(input);
const id = randomUUID();
const rows = await this.database.query(
`insert into employees(
id, organization_id, first_name, last_name, display_name,
employee_number, preferred_locale, status, started_at
)
values ($1,$2,$3,$4,$5,$6,$7,'active',current_date)
returning *`,
[
id,
input.organizationId,
input.firstName,
input.lastName,
input.displayName ?? null,
input.employeeNumber ?? null,
input.preferredLocale ?? null,
],
);
return rows[0];
}
async update(
organizationId: string,
employeeId: string,
patch: {
firstName?: string;
lastName?: string;
displayName?: string | null;
employeeNumber?: string | null;
preferredLocale?: string | null;
},
) {
assertEmployeeUpdateInput(organizationId, employeeId, patch);
const current = await this.database.query<any>(
`select * from employees
where id = $1 and organization_id = $2`,
[employeeId, organizationId],
);
if (!current[0]) {
throw new DomainConflictError('employee.not_found');
}
const next = { ...current[0], ...{
first_name: patch.firstName ?? current[0].first_name,
last_name: patch.lastName ?? current[0].last_name,
display_name: patch.displayName === undefined ? current[0].display_name : patch.displayName,
employee_number: patch.employeeNumber === undefined ? current[0].employee_number : patch.employeeNumber,
preferred_locale: patch.preferredLocale === undefined ? current[0].preferred_locale : patch.preferredLocale,
}};
const rows = await this.database.query(
`update employees
set first_name=$1,last_name=$2,display_name=$3,employee_number=$4,
preferred_locale=$5,updated_at=now(),version=version+1
where id=$6 and organization_id=$7
returning *`,
[
next.first_name,
next.last_name,
next.display_name,
next.employee_number,
next.preferred_locale,
employeeId,
organizationId,
],
);
return rows[0];
}
async terminate(
organizationId: string,
employeeId: string,
actorUserId?: string,
) {
assertEmployeeReferenceInput(organizationId, employeeId, actorUserId);
return this.database.transaction(async (tx: DatabaseQueryClient) => {
const rows = await tx.query(
`update employees
set status='terminated', ended_at=current_date,
updated_at=now(), version=version+1
where id=$1 and organization_id=$2 and status <> 'terminated'
returning *`,
[employeeId, organizationId],
);
if (!rows[0]) {
throw new DomainConflictError('employee.invalid_termination_state');
}
await tx.query(
`update memberships
set status='revoked', revoked_at=coalesce(revoked_at, now()),
updated_at=now(), version=version+1
where organization_id=$1 and employee_id=$2 and status <> 'revoked'`,
[organizationId, employeeId],
);
await this.audit.record({
organizationId,
actorUserId,
eventType: 'employee.terminated',
entityType: 'employee',
entityId: employeeId,
metadata: { membershipsRevoked: true },
}, tx);
return rows[0];
});
}
}
