Commit ec80f55d authored by Administrator's avatar Administrator

Update 22 files via Son of Anton

parent 0b0931ce
...@@ -63,6 +63,12 @@ import { ApiKeysModule } from './modules/api-keys/api-keys.module'; ...@@ -63,6 +63,12 @@ import { ApiKeysModule } from './modules/api-keys/api-keys.module';
import { WebhooksModule } from './modules/webhooks/webhooks.module'; import { WebhooksModule } from './modules/webhooks/webhooks.module';
import { SearchModule } from './modules/search/search.module'; import { SearchModule } from './modules/search/search.module';
// ─── Phase 3C: Documents & Offboarding ──────────────────────
import { ContractsModule } from './modules/contracts/contracts.module';
import { PoliciesModule } from './modules/policies/policies.module';
import { OffboardingModule } from './modules/offboarding/offboarding.module';
import { PdfModule } from './modules/pdf/pdf.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard'; import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
...@@ -122,6 +128,11 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -122,6 +128,11 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
ApiKeysModule, ApiKeysModule,
WebhooksModule, WebhooksModule,
SearchModule, SearchModule,
// Phase 3C
ContractsModule,
PoliciesModule,
OffboardingModule,
PdfModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ContractsService } from './contracts.service';
import { ContractFilterDto } from './dto/contract-filter.dto';
import { UpdateContractMetadataDto } from './dto/update-contract.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('contracts')
export class ContractsController {
constructor(private readonly contractsService: ContractsService) {}
@Get()
async findAll(@Query() filter: ContractFilterDto, @CurrentUser() user: RequestUser) {
return this.contractsService.findAll(filter, user);
}
@Get('expiring')
@Roles('SUPER_ADMIN', 'ADMIN')
async getExpiring(@Query('days') days: string, @CurrentUser() user: RequestUser) {
const daysOut = days ? parseInt(days, 10) : 90;
return this.contractsService.getExpiringContracts(daysOut, user);
}
@Get('user/:userId')
async findByUser(@Param('userId') userId: string, @CurrentUser() user: RequestUser) {
return this.contractsService.findByUserId(userId, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.contractsService.findById(id, user);
}
@Get(':id/snapshot')
async getSnapshot(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.contractsService.getContractSnapshot(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async updateMetadata(
@Param('id') id: string,
@Body() dto: UpdateContractMetadataDto,
@CurrentUser() user: RequestUser,
) {
return this.contractsService.updateMetadata(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.contractsService.delete(id, user);
return { message: 'Contract record deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@Module({
controllers: [ContractsController],
providers: [ContractsService],
exports: [ContractsService],
})
export class ContractsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { ContractFilterDto } from './dto/contract-filter.dto';
import { UpdateContractMetadataDto } from './dto/update-contract.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class ContractsService {
private readonly logger = new Logger(ContractsService.name);
constructor(private readonly prisma: PrismaService) {}
async findAll(filter: ContractFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
}
if (currentUser.role === 'TEAM_LEAD') {
throw new ForbiddenException('Project Leaders cannot access contract records');
}
if (filter.userId) where.userId = filter.userId;
if (filter.status) where.status = filter.status;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.expiringBefore || filter.expiringAfter) {
where.endDate = {};
if (filter.expiringBefore) where.endDate.lte = new Date(filter.expiringBefore);
if (filter.expiringAfter) where.endDate.gte = new Date(filter.expiringAfter);
}
const [data, total] = await Promise.all([
this.prisma.contract.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true } },
},
}),
this.prisma.contract.count({ where }),
]);
const sanitized = data.map((c: any) => {
if (currentUser.role === 'CONTRACTOR') {
const { notes, ...rest } = c;
return rest;
}
return c;
});
return buildPaginatedResponse(sanitized, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const contract = await this.prisma.contract.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true, email: true } },
},
});
if (!contract) throw new NotFoundException('Contract not found');
if (currentUser.role === 'CONTRACTOR' && contract.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own contracts');
}
if (currentUser.role === 'TEAM_LEAD') {
throw new ForbiddenException('Project Leaders cannot access contract records');
}
return contract;
}
async findByUserId(userId: string, currentUser: RequestUser): Promise<any[]> {
if (currentUser.role === 'CONTRACTOR' && userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own contracts');
}
if (currentUser.role === 'TEAM_LEAD') {
throw new ForbiddenException('Project Leaders cannot access contract records');
}
return this.prisma.contract.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async updateMetadata(id: string, dto: UpdateContractMetadataDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit contract metadata');
}
const contract = await this.prisma.contract.findUnique({ where: { id } });
if (!contract) throw new NotFoundException('Contract not found');
const updateData: any = {};
if (dto.contractType !== undefined) updateData.contractType = dto.contractType;
if (dto.startDate !== undefined) updateData.startDate = new Date(dto.startDate);
if (dto.endDate !== undefined) updateData.endDate = dto.endDate ? new Date(dto.endDate) : null;
if (dto.notes !== undefined) updateData.notes = dto.notes;
if (dto.status !== undefined) updateData.status = dto.status;
const updated = await this.prisma.contract.update({
where: { id },
data: updateData,
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(`Contract ${id} metadata updated by ${currentUser.email}`);
return updated;
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete contract records');
}
const contract = await this.prisma.contract.findUnique({ where: { id } });
if (!contract) throw new NotFoundException('Contract not found');
await this.prisma.contract.delete({ where: { id } });
this.logger.log(`Contract ${id} deleted by ${currentUser.email}`);
}
async getExpiringContracts(daysOut: number, currentUser: RequestUser): Promise<any[]> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view expiring contracts');
}
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + daysOut);
return this.prisma.contract.findMany({
where: {
endDate: { lte: targetDate, gte: new Date() },
status: 'ACTIVE',
},
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, status: true } },
},
orderBy: { endDate: 'asc' },
});
}
async getContractSnapshot(id: string, currentUser: RequestUser): Promise<{ html: string; signatureData: any }> {
const contract = await this.prisma.contract.findUnique({
where: { id },
select: { userId: true, snapshotHtml: true, signatureData: true },
});
if (!contract) throw new NotFoundException('Contract not found');
if (currentUser.role === 'CONTRACTOR' && contract.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own contracts');
}
if (currentUser.role === 'TEAM_LEAD') {
throw new ForbiddenException('Project Leaders cannot access contracts');
}
return {
html: contract.snapshotHtml || '',
signatureData: contract.signatureData || null,
};
}
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class ContractFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
contractType?: string;
@IsOptional()
@IsDateString()
expiringBefore?: string;
@IsOptional()
@IsDateString()
expiringAfter?: string;
}
\ No newline at end of file
export class ContractResponseDto {
id: string;
userId: string;
contractType: string;
status: string;
startDate: string | null;
endDate: string | null;
signedAt: string | null;
snapshotHtml: string | null;
baseSalaryAtSigning: number | null;
actualSalaryAtSigning: number | null;
scheduleAtSigning: any;
signatureData: any;
notes: string | null;
user: {
id: string;
firstName: string;
lastName: string;
};
createdAt: string;
updatedAt: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
export class UpdateContractMetadataDto {
@IsOptional()
@IsString()
contractType?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsString()
status?: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsDateString, MinLength } from 'class-validator';
export class CreateTerminationDto {
@IsString()
userId: string;
@IsString()
terminationType: string; // VOLUNTARY, FOR_CAUSE, MUTUAL_AGREEMENT, CONTRACT_EXPIRY
@IsString()
@MinLength(100, { message: 'Termination reason must be at least 100 characters' })
reason: string;
@IsDateString()
effectiveDate: string;
@IsOptional()
@IsString()
notes?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class TerminationFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
terminationType?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { OffboardingService } from './offboarding.service';
import { CreateTerminationDto } from './dto/create-termination.dto';
import { TerminationFilterDto } from './dto/termination-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('offboarding')
export class OffboardingController {
constructor(private readonly offboardingService: OffboardingService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async initiate(@Body() dto: CreateTerminationDto, @CurrentUser() user: RequestUser) {
return this.offboardingService.initiate(dto, user);
}
@Get()
@Roles('SUPER_ADMIN', 'ADMIN')
async findAll(@Query() filter: TerminationFilterDto, @CurrentUser() user: RequestUser) {
return this.offboardingService.findAll(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.offboardingService.findById(id, user);
}
@Put(':id/checklist/:itemId')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async updateChecklist(
@Param('id') id: string,
@Param('itemId') itemId: string,
@Body('completed') completed: boolean,
@CurrentUser() user: RequestUser,
) {
return this.offboardingService.updateChecklist(id, itemId, completed, user);
}
@Post(':id/complete')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async complete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.offboardingService.complete(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async update(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
return this.offboardingService.update(id, data, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.offboardingService.delete(id, user);
return { message: 'Termination record deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { OffboardingController } from './offboarding.controller';
import { OffboardingService } from './offboarding.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [OffboardingController],
providers: [OffboardingService],
exports: [OffboardingService],
})
export class OffboardingModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateTerminationDto } from './dto/create-termination.dto';
import { TerminationFilterDto } from './dto/termination-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class OffboardingService {
private readonly logger = new Logger(OffboardingService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async initiate(dto: CreateTerminationDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can initiate terminations');
}
// For-cause termination requires Super Admin
if (dto.terminationType === 'FOR_CAUSE' && currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can initiate for-cause terminations');
}
const validTypes = ['VOLUNTARY', 'FOR_CAUSE', 'MUTUAL_AGREEMENT', 'CONTRACT_EXPIRY'];
if (!validTypes.includes(dto.terminationType)) {
throw new BadRequestException(`Termination type must be one of: ${validTypes.join(', ')}`);
}
const contractor = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!contractor) throw new NotFoundException('Contractor not found');
if (contractor.status === 'OFFBOARDED') {
throw new BadRequestException('Contractor is already offboarded');
}
const effectiveDate = new Date(dto.effectiveDate);
if (effectiveDate < new Date()) {
throw new BadRequestException('Effective date cannot be in the past');
}
// Create termination record
const termination = await this.prisma.termination.create({
data: {
userId: dto.userId,
terminationType: dto.terminationType,
reason: dto.reason,
effectiveDate,
noticeDate: new Date(),
notes: dto.notes || null,
status: 'INITIATED',
initiatedById: currentUser.id,
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
// Create offboarding checklist
await this.createOffboardingChecklist(termination.id, dto.userId);
// Notify the contractor
try {
await this.notificationsService.create({
userId: dto.userId,
type: 'BLOCKING',
category: 'SYSTEM',
title: 'Termination Notice',
message: `Your engagement is being terminated effective ${effectiveDate.toISOString().split('T')[0]}. Type: ${dto.terminationType.replace('_', ' ')}. Please review the details.`,
actionUrl: `/offboarding/${termination.id}`,
isBlocking: true,
entityType: 'termination',
entityId: termination.id,
});
} catch (err) {
this.logger.warn(`Failed to send termination notification: ${err.message}`);
}
// Notify Super Admin if initiated by Admin
if (currentUser.role === 'ADMIN') {
const superAdmins = await this.prisma.user.findMany({
where: { role: 'SUPER_ADMIN', status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const sa of superAdmins) {
try {
await this.notificationsService.create({
userId: sa.id,
type: 'IMPORTANT',
category: 'SYSTEM',
title: `Termination Initiated: ${contractor.firstName} ${contractor.lastName}`,
message: `Admin initiated termination for ${contractor.firstName} ${contractor.lastName}. Type: ${dto.terminationType}. Effective: ${effectiveDate.toISOString().split('T')[0]}.`,
actionUrl: `/admin/offboarding/${termination.id}`,
entityType: 'termination',
entityId: termination.id,
});
} catch { /* non-critical */ }
}
}
this.logger.log(
`Termination initiated for ${contractor.firstName} ${contractor.lastName} by ${currentUser.email} — type: ${dto.terminationType}, effective: ${effectiveDate.toISOString().split('T')[0]}`,
);
return termination;
}
async findAll(filter: TerminationFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view termination records');
}
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (filter.userId) where.userId = filter.userId;
if (filter.terminationType) where.terminationType = filter.terminationType;
if (filter.status) where.status = filter.status;
if (filter.dateFrom || filter.dateTo) {
where.effectiveDate = {};
if (filter.dateFrom) where.effectiveDate.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.effectiveDate.lte = new Date(filter.dateTo);
}
const [data, total] = await Promise.all([
this.prisma.termination.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.termination.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const termination = await this.prisma.termination.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true, actualSalaryPiasters: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
checklist: { orderBy: { position: 'asc' } },
},
});
if (!termination) throw new NotFoundException('Termination record not found');
if (currentUser.role === 'CONTRACTOR' && termination.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own termination record');
}
// Calculate final settlement
const settlement = await this.calculateFinalSettlement(termination);
return { ...termination, settlement };
}
async updateChecklist(
terminationId: string,
checklistItemId: string,
completed: boolean,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can update offboarding checklist');
}
const item = await this.prisma.offboardingChecklistItem.findFirst({
where: { id: checklistItemId, terminationId },
});
if (!item) throw new NotFoundException('Checklist item not found');
const updated = await this.prisma.offboardingChecklistItem.update({
where: { id: checklistItemId },
data: {
isCompleted: completed,
completedAt: completed ? new Date() : null,
completedById: completed ? currentUser.id : null,
},
});
// Check if all items are complete
const allItems = await this.prisma.offboardingChecklistItem.findMany({
where: { terminationId },
});
const allComplete = allItems.every((i: any) => i.isCompleted);
if (allComplete) {
await this.prisma.termination.update({
where: { id: terminationId },
data: { status: 'CHECKLIST_COMPLETE' },
});
}
return updated;
}
async complete(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can complete terminations');
}
const termination = await this.prisma.termination.findUnique({
where: { id },
include: { user: { select: { id: true, firstName: true, lastName: true } } },
});
if (!termination) throw new NotFoundException('Termination record not found');
if (termination.status === 'COMPLETED') {
throw new BadRequestException('Termination is already completed');
}
// Deactivate the user
await this.prisma.user.update({
where: { id: termination.userId },
data: { status: 'OFFBOARDED' },
});
// Revoke all active sessions
await this.prisma.session.updateMany({
where: { userId: termination.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
// Unassign from all cards
const assignedCards = await this.prisma.card.findMany({
where: { assignees: { some: { id: termination.userId } }, deletedAt: null, completedAt: null },
select: { id: true, cardNumber: true, title: true },
});
for (const card of assignedCards) {
await this.prisma.card.update({
where: { id: card.id },
data: { assignees: { disconnect: { id: termination.userId } } },
});
try {
await this.prisma.cardActivity.create({
data: {
cardId: card.id,
userId: currentUser.id,
action: 'UNASSIGNED',
metadata: {
unassignedUserId: termination.userId,
reason: 'Contractor termination',
},
},
});
} catch { /* non-critical */ }
}
// Update contract status
await this.prisma.contract.updateMany({
where: { userId: termination.userId, status: 'ACTIVE' },
data: { status: 'TERMINATED' },
});
// Update termination record
const updated = await this.prisma.termination.update({
where: { id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
completedById: currentUser.id,
},
});
this.logger.log(
`Termination completed for ${termination.user.firstName} ${termination.user.lastName} by ${currentUser.email}. ${assignedCards.length} cards unassigned.`,
);
return updated;
}
async update(id: string, data: any, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit termination records');
}
const termination = await this.prisma.termination.findUnique({ where: { id } });
if (!termination) throw new NotFoundException('Termination record not found');
const updateData: any = {};
if (data.reason !== undefined) updateData.reason = data.reason;
if (data.effectiveDate !== undefined) updateData.effectiveDate = new Date(data.effectiveDate);
if (data.notes !== undefined) updateData.notes = data.notes;
if (data.terminationType !== undefined) updateData.terminationType = data.terminationType;
if (data.status !== undefined) updateData.status = data.status;
return this.prisma.termination.update({ where: { id }, data: updateData });
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete termination records');
}
const termination = await this.prisma.termination.findUnique({ where: { id } });
if (!termination) throw new NotFoundException('Termination record not found');
// Delete checklist items first
await this.prisma.offboardingChecklistItem.deleteMany({ where: { terminationId: id } });
await this.prisma.termination.delete({ where: { id } });
this.logger.log(`Termination ${id} deleted by ${currentUser.email}`);
}
private async createOffboardingChecklist(terminationId: string, userId: string): Promise<void> {
const items = [
{ title: 'Final payroll calculated and approved', verifiedBy: 'SYSTEM', position: 0 },
{ title: 'Source control access revoked', verifiedBy: 'PROJECT_LEADER', position: 1 },
{ title: 'All assigned cards reassigned or unassigned', verifiedBy: 'PROJECT_LEADER', position: 2 },
{ title: 'Company assets returned', verifiedBy: 'ADMIN', position: 3 },
{ title: 'NDA/IP obligations reminder sent', verifiedBy: 'SYSTEM', position: 4 },
{ title: 'Final settlement approved', verifiedBy: 'SUPER_ADMIN', position: 5 },
{ title: 'Account deactivated', verifiedBy: 'SYSTEM', position: 6 },
];
for (const item of items) {
await this.prisma.offboardingChecklistItem.create({
data: {
terminationId,
title: item.title,
verifiedBy: item.verifiedBy,
position: item.position,
isCompleted: false,
},
});
}
}
private async calculateFinalSettlement(termination: any): Promise<any> {
const now = new Date();
const effectiveDate = new Date(termination.effectiveDate);
const month = effectiveDate.getMonth() + 1;
const year = effectiveDate.getFullYear();
const user = await this.prisma.user.findUnique({
where: { id: termination.userId },
select: {
actualSalaryPiasters: true,
baseSalaryPiasters: true,
weeklySchedule: true,
},
});
if (!user) return null;
const actualSalary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
// Calculate prorated salary for days worked in final month
const { getScheduledDaysOfWeek, getWorkingDaysInMonth } = await import('../../common/utils/date.util');
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const totalWorkingDays = getWorkingDaysInMonth(year, month, scheduledDays);
// Count working days from start of month to effective date
let workedDays = 0;
const monthStart = new Date(year, month - 1, 1);
for (let d = new Date(monthStart); d <= effectiveDate && d.getMonth() === month - 1; d.setDate(d.getDate() + 1)) {
if (scheduledDays.includes(d.getDay())) {
workedDays++;
}
}
const proratedSalary = totalWorkingDays > 0
? Math.round((actualSalary * workedDays) / totalWorkingDays)
: 0;
// Outstanding bounties (already paid, for completed cards)
const bounties = await this.prisma.bountyPayout.aggregate({
where: {
userId: termination.userId,
payrollMonth: month,
payrollYear: year,
revokedAt: null,
},
_sum: { amountPiasters: true },
});
// Applied deductions
const deductions = await this.prisma.deduction.aggregate({
where: {
userId: termination.userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
_sum: { appliedAmountPiasters: true },
});
// Adjustments
const positiveAdj = await this.prisma.adjustment.aggregate({
where: {
userId: termination.userId,
effectiveMonth: month,
effectiveYear: year,
type: 'POSITIVE',
status: 'APPROVED',
},
_sum: { amountPiasters: true },
});
const negativeAdj = await this.prisma.adjustment.aggregate({
where: {
userId: termination.userId,
effectiveMonth: month,
effectiveYear: year,
type: 'NEGATIVE',
status: 'APPROVED',
},
_sum: { amountPiasters: true },
});
const totalBounties = bounties._sum.amountPiasters || 0;
const totalDeductions = deductions._sum.appliedAmountPiasters || 0;
const totalPositiveAdj = positiveAdj._sum.amountPiasters || 0;
const totalNegativeAdj = negativeAdj._sum.amountPiasters || 0;
const netPayable = proratedSalary + totalBounties + totalPositiveAdj - totalDeductions - totalNegativeAdj;
return {
month,
year,
workedDays,
totalWorkingDays,
proratedSalaryPiasters: proratedSalary,
totalBountiesPiasters: totalBounties,
totalDeductionsPiasters: totalDeductions,
totalPositiveAdjustmentsPiasters: totalPositiveAdj,
totalNegativeAdjustmentsPiasters: totalNegativeAdj,
netPayablePiasters: Math.max(0, netPayable),
};
}
}
\ No newline at end of file
import {
Controller,
Get,
Param,
Query,
Res,
} from '@nestjs/common';
import { Response } from 'express';
import { PdfService } from './pdf.service';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('pdf')
export class PdfController {
constructor(private readonly pdfService: PdfService) {}
@Get('payslip/:userId')
async getPayslipData(
@Param('userId') userId: string,
@Query('month') month: string,
@Query('year') year: string,
@CurrentUser() user: RequestUser,
) {
const m = month ? parseInt(month, 10) : new Date().getMonth() + 1;
const y = year ? parseInt(year, 10) : new Date().getFullYear();
return this.pdfService.generatePayslipData(userId, m, y, user);
}
@Get('payslip/my')
async getMyPayslipData(
@Query('month') month: string,
@Query('year') year: string,
@CurrentUser() user: RequestUser,
) {
const m = month ? parseInt(month, 10) : new Date().getMonth() + 1;
const y = year ? parseInt(year, 10) : new Date().getFullYear();
return this.pdfService.generatePayslipData(user.id, m, y, user);
}
@Get('evaluation/:evaluationId')
async getEvaluationData(
@Param('evaluationId') evaluationId: string,
@CurrentUser() user: RequestUser,
) {
return this.pdfService.generateEvaluationData(evaluationId, user);
}
@Get('payroll-summary')
@Roles('SUPER_ADMIN', 'ADMIN')
async getPayrollSummaryData(
@Query('month') month: string,
@Query('year') year: string,
@CurrentUser() user: RequestUser,
) {
const m = month ? parseInt(month, 10) : new Date().getMonth() + 1;
const y = year ? parseInt(year, 10) : new Date().getFullYear();
return this.pdfService.generatePayrollSummaryData(m, y, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PdfController } from './pdf.controller';
import { PdfService } from './pdf.service';
@Module({
controllers: [PdfController],
providers: [PdfService],
exports: [PdfService],
})
export class PdfModule {}
\ No newline at end of file
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { piasterToEgp, formatEgp } from '../../common/utils/salary.util';
@Injectable()
export class PdfService {
private readonly logger = new Logger(PdfService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Generate payslip data for a contractor for a specific month.
* Returns structured JSON that the frontend renders as PDF using @react-pdf/renderer.
*/
async generatePayslipData(userId: string, month: number, year: number, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR' && currentUser.id !== userId) {
throw new ForbiddenException('You can only generate your own payslips');
}
if (currentUser.role === 'TEAM_LEAD') {
throw new ForbiddenException('Project Leaders cannot access payslip data');
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
firstName: true,
lastName: true,
username: true,
contractorType: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
bankName: true,
bankAccountNumber: true,
bankAccountHolderName: true,
},
});
if (!user) throw new NotFoundException('Contractor not found');
// Get payroll line for this month
const payrollLine = await this.prisma.payrollLine.findFirst({
where: { userId, payroll: { month, year } },
include: { payroll: { select: { status: true, paidAt: true } } },
});
// Get individual bounties
const bounties = await this.prisma.bountyPayout.findMany({
where: { userId, payrollMonth: month, payrollYear: year, revokedAt: null },
select: { cardNumber: true, cardTitle: true, amountPiasters: true, paidAt: true },
});
// Get individual deductions
const deductions = await this.prisma.deduction.findMany({
where: {
userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
select: { category: true, subCategory: true, description: true, appliedAmountPiasters: true, violationDate: true },
});
// Get adjustments
const adjustments = await this.prisma.adjustment.findMany({
where: {
userId,
effectiveMonth: month,
effectiveYear: year,
status: 'APPROVED',
},
select: { type: true, category: true, description: true, amountPiasters: true },
});
const actualSalary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
const totalBounties = bounties.reduce((sum, b) => sum + b.amountPiasters, 0);
const totalDeductions = deductions.reduce((sum, d) => sum + (d.appliedAmountPiasters || 0), 0);
const totalPositiveAdj = adjustments.filter((a) => a.type === 'POSITIVE').reduce((sum, a) => sum + a.amountPiasters, 0);
const totalNegativeAdj = adjustments.filter((a) => a.type === 'NEGATIVE').reduce((sum, a) => sum + a.amountPiasters, 0);
const netPayable = actualSalary + totalBounties + totalPositiveAdj - totalDeductions - totalNegativeAdj;
return {
generatedAt: new Date().toISOString(),
period: { month, year },
contractor: {
name: `${user.firstName} ${user.lastName}`,
username: user.username,
type: user.contractorType,
bankName: user.bankName,
bankAccount: user.bankAccountNumber ? `****${user.bankAccountNumber.slice(-4)}` : null,
bankHolder: user.bankAccountHolderName,
},
salary: {
actualSalaryPiasters: actualSalary,
actualSalaryEGP: piasterToEgp(actualSalary),
},
bounties: bounties.map((b) => ({
card: b.cardNumber,
title: b.cardTitle,
amountPiasters: b.amountPiasters,
amountEGP: piasterToEgp(b.amountPiasters),
})),
totalBounties: { piasters: totalBounties, egp: piasterToEgp(totalBounties) },
deductions: deductions.map((d) => ({
category: `${d.category}${d.subCategory}`,
description: d.description?.substring(0, 100),
amountPiasters: d.appliedAmountPiasters,
amountEGP: piasterToEgp(d.appliedAmountPiasters || 0),
date: d.violationDate,
})),
totalDeductions: { piasters: totalDeductions, egp: piasterToEgp(totalDeductions) },
adjustments: adjustments.map((a) => ({
type: a.type,
category: a.category,
description: a.description?.substring(0, 100),
amountPiasters: a.amountPiasters,
amountEGP: piasterToEgp(a.amountPiasters),
})),
totalPositiveAdjustments: { piasters: totalPositiveAdj, egp: piasterToEgp(totalPositiveAdj) },
totalNegativeAdjustments: { piasters: totalNegativeAdj, egp: piasterToEgp(totalNegativeAdj) },
netPayable: { piasters: Math.max(0, netPayable), egp: piasterToEgp(Math.max(0, netPayable)) },
payrollStatus: payrollLine?.payroll?.status || 'NOT_CALCULATED',
paidAt: payrollLine?.payroll?.paidAt || null,
};
}
/**
* Generate evaluation report data.
*/
async generateEvaluationData(evaluationId: string, currentUser: RequestUser): Promise<any> {
const evaluation = await this.prisma.evaluation.findUnique({
where: { id: evaluationId },
include: {
user: {
select: {
id: true, firstName: true, lastName: true,
contractorType: true, username: true,
},
},
},
});
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (currentUser.role === 'CONTRACTOR' && evaluation.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own evaluations');
}
return {
generatedAt: new Date().toISOString(),
period: { month: evaluation.month, year: evaluation.year },
contractor: {
name: `${evaluation.user.firstName} ${evaluation.user.lastName}`,
username: evaluation.user.username,
type: evaluation.user.contractorType,
},
technical: {
codeQuality: evaluation.techCodeQuality,
taskCompletion: evaluation.techTaskCompletion,
taskCompletionAuto: evaluation.techTaskCompletionAuto,
deadlineCompliance: evaluation.techDeadlineCompliance,
deadlineComplianceAuto: evaluation.techDeadlineComplianceAuto,
growth: evaluation.techGrowth,
problemSolving: evaluation.techProblemSolving,
overrideJustification: evaluation.techOverrideJustification,
score: evaluation.technicalScore,
notes: {
codeQuality: evaluation.techCodeQualityNotes,
growth: evaluation.techGrowthNotes,
problemSolving: evaluation.techProblemSolvingNotes,
},
},
professional: {
reportingCompliance: evaluation.profReportingCompliance,
reportingComplianceAuto: evaluation.profReportingComplianceAuto,
communication: evaluation.profCommunication,
collaboration: evaluation.profCollaboration,
reliability: evaluation.profReliability,
policyCompliance: evaluation.profPolicyCompliance,
policyComplianceAuto: evaluation.profPolicyComplianceAuto,
overrideJustification: evaluation.profOverrideJustification,
score: evaluation.professionalScore,
notes: {
communication: evaluation.profCommunicationNotes,
collaboration: evaluation.profCollaborationNotes,
reliability: evaluation.profReliabilityNotes,
},
},
overall: {
score: evaluation.overallScore,
rating: evaluation.rating,
},
systemMetrics: evaluation.systemMetrics,
contractorResponse: evaluation.responseText,
status: evaluation.status,
compiledAt: evaluation.compiledAt,
acknowledgedAt: evaluation.acknowledgedAt,
};
}
/**
* Generate payroll summary data for a month.
*/
async generatePayrollSummaryData(month: number, year: number, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can generate payroll summaries');
}
const payroll = await this.prisma.payroll.findUnique({
where: { month_year: { month, year } },
include: {
lines: {
include: {
user: { select: { id: true, firstName: true, lastName: true, contractorType: true } },
},
orderBy: { user: { lastName: 'asc' } },
},
},
});
if (!payroll) throw new NotFoundException(`Payroll for ${month}/${year} not found`);
return {
generatedAt: new Date().toISOString(),
period: { month, year },
status: payroll.status,
summary: {
contractorCount: payroll.contractorCount,
totalGrossPiasters: payroll.totalGrossPiasters,
totalDeductionsPiasters: payroll.totalDeductionsPiasters,
totalBountiesPiasters: payroll.totalBountiesPiasters,
totalAdjustmentsPiasters: payroll.totalAdjustmentsPiasters,
totalNetPiasters: payroll.totalNetPiasters,
totalGrossEGP: piasterToEgp(payroll.totalGrossPiasters || 0),
totalDeductionsEGP: piasterToEgp(payroll.totalDeductionsPiasters || 0),
totalBountiesEGP: piasterToEgp(payroll.totalBountiesPiasters || 0),
totalAdjustmentsEGP: piasterToEgp(payroll.totalAdjustmentsPiasters || 0),
totalNetEGP: piasterToEgp(payroll.totalNetPiasters || 0),
},
lines: payroll.lines.map((line: any) => ({
contractor: {
name: `${line.user.firstName} ${line.user.lastName}`,
type: line.user.contractorType,
},
actualSalaryPiasters: line.actualSalaryPiasters,
totalBountiesPiasters: line.totalBountiesPiasters,
totalDeductionsPiasters: line.totalDeductionsPiasters,
totalAdjustmentsPiasters: line.totalAdjustmentsPiasters,
netPayablePiasters: line.netPayablePiasters,
actualSalaryEGP: piasterToEgp(line.actualSalaryPiasters || 0),
totalBountiesEGP: piasterToEgp(line.totalBountiesPiasters || 0),
totalDeductionsEGP: piasterToEgp(line.totalDeductionsPiasters || 0),
totalAdjustmentsEGP: piasterToEgp(line.totalAdjustmentsPiasters || 0),
netPayableEGP: piasterToEgp(line.netPayablePiasters || 0),
})),
approvedBy: payroll.approvedById,
approvedAt: payroll.approvedAt,
paidAt: payroll.paidAt,
};
}
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength } from 'class-validator';
export class CreatePolicyDto {
@IsString()
@MinLength(2)
@MaxLength(200)
name: string;
@IsString()
@MinLength(50)
content: string;
@IsOptional()
@IsBoolean()
requiresAcknowledgment?: boolean;
@IsOptional()
@IsString()
category?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class PolicyFilterDto extends PaginationDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
requiresAcknowledgment?: boolean;
}
\ No newline at end of file
import { IsString, IsOptional, IsBoolean, MinLength, MaxLength } from 'class-validator';
export class UpdatePolicyDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
name?: string;
@IsOptional()
@IsString()
@MinLength(50)
content?: string;
@IsOptional()
@IsBoolean()
requiresAcknowledgment?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsString()
category?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { PoliciesService } from './policies.service';
import { CreatePolicyDto } from './dto/create-policy.dto';
import { UpdatePolicyDto } from './dto/update-policy.dto';
import { PolicyFilterDto } from './dto/policy-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('policies')
export class PoliciesController {
constructor(private readonly policiesService: PoliciesService) {}
@Post()
@Roles('SUPER_ADMIN')
async create(@Body() dto: CreatePolicyDto, @CurrentUser() user: RequestUser) {
return this.policiesService.create(dto, user);
}
@Get()
async findAll(@Query() filter: PolicyFilterDto, @CurrentUser() user: RequestUser) {
return this.policiesService.findAll(filter, user);
}
@Get('unacknowledged')
async getUnacknowledged(@CurrentUser() user: RequestUser) {
return this.policiesService.getUnacknowledgedPolicies(user.id);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.policiesService.findById(id, user);
}
@Get(':id/versions')
@Roles('SUPER_ADMIN')
async getVersions(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.policiesService.getPreviousVersions(id, user);
}
@Get(':id/acknowledgments')
@Roles('SUPER_ADMIN', 'ADMIN')
async getAcknowledgments(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.policiesService.getAcknowledgmentStatus(id, user);
}
@Post(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.policiesService.acknowledge(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async update(@Param('id') id: string, @Body() dto: UpdatePolicyDto, @CurrentUser() user: RequestUser) {
return this.policiesService.update(id, dto, user);
}
@Post(':id/archive')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async archive(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.policiesService.archive(id, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.policiesService.delete(id, user);
return { message: 'Policy deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PoliciesController } from './policies.controller';
import { PoliciesService } from './policies.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [PoliciesController],
providers: [PoliciesService],
exports: [PoliciesService],
})
export class PoliciesModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreatePolicyDto } from './dto/create-policy.dto';
import { UpdatePolicyDto } from './dto/update-policy.dto';
import { PolicyFilterDto } from './dto/policy-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class PoliciesService {
private readonly logger = new Logger(PoliciesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreatePolicyDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can create policies');
}
const policy = await this.prisma.policy.create({
data: {
name: dto.name,
content: dto.content,
version: 1,
requiresAcknowledgment: dto.requiresAcknowledgment ?? true,
category: dto.category || 'GENERAL',
isActive: true,
publishedAt: new Date(),
createdById: currentUser.id,
},
});
// If requires acknowledgment, notify all active contractors
if (policy.requiresAcknowledgment) {
await this.sendPolicyNotifications(policy);
}
this.logger.log(`Policy "${dto.name}" v1 created by ${currentUser.email}`);
return policy;
}
async findAll(filter: PolicyFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.category) where.category = filter.category;
if (filter.requiresAcknowledgment !== undefined) where.requiresAcknowledgment = filter.requiresAcknowledgment;
// Contractors only see active policies
if (currentUser.role === 'CONTRACTOR') {
where.isActive = true;
}
const [data, total] = await Promise.all([
this.prisma.policy.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { updatedAt: filter.sortOrder || 'desc' },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
_count: { select: { acknowledgments: true } },
},
}),
this.prisma.policy.count({ where }),
]);
// For each policy, get the current user's acknowledgment status
const enriched = await Promise.all(
data.map(async (policy: any) => {
let userAcknowledged = false;
let userAcknowledgedAt: Date | null = null;
let acknowledgedVersion: number | null = null;
if (currentUser.role === 'CONTRACTOR') {
const ack = await this.prisma.policyAcknowledgment.findFirst({
where: {
policyId: policy.id,
userId: currentUser.id,
version: policy.version,
},
});
if (ack) {
userAcknowledged = true;
userAcknowledgedAt = ack.acknowledgedAt;
acknowledgedVersion = ack.version;
}
}
// Get total active contractors for acknowledgment tracking
let totalContractors = 0;
if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') {
totalContractors = await this.prisma.user.count({
where: { role: 'CONTRACTOR', status: { in: ['ACTIVE', 'ON_PIP'] }, deletedAt: null },
});
}
return {
...policy,
acknowledgmentCount: (policy as any)._count?.acknowledgments || 0,
totalContractors,
userAcknowledged,
userAcknowledgedAt,
acknowledgedVersion,
needsReAcknowledgment: acknowledgedVersion !== null && acknowledgedVersion < policy.version,
};
}),
);
return buildPaginatedResponse(enriched, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const policy = await this.prisma.policy.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!policy) throw new NotFoundException('Policy not found');
if (currentUser.role === 'CONTRACTOR' && !policy.isActive) {
throw new NotFoundException('Policy not found');
}
// Get acknowledgment stats for admin
let acknowledgmentStats: any = null;
if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') {
const acks = await this.prisma.policyAcknowledgment.findMany({
where: { policyId: id, version: policy.version },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
orderBy: { acknowledgedAt: 'desc' },
});
const totalContractors = await this.prisma.user.count({
where: { role: 'CONTRACTOR', status: { in: ['ACTIVE', 'ON_PIP'] }, deletedAt: null },
});
const acknowledgedUserIds = new Set(acks.map((a: any) => a.userId));
const unacknowledgedUsers = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
id: { notIn: Array.from(acknowledgedUserIds) },
},
select: { id: true, firstName: true, lastName: true, avatar: true },
});
acknowledgmentStats = {
totalContractors,
acknowledgedCount: acks.length,
unacknowledgedCount: unacknowledgedUsers.length,
acknowledged: acks.map((a: any) => ({
user: a.user,
acknowledgedAt: a.acknowledgedAt,
version: a.version,
})),
unacknowledged: unacknowledgedUsers,
};
}
// Get contractor's own acknowledgment
let userAcknowledgment: any = null;
if (currentUser.role === 'CONTRACTOR') {
const ack = await this.prisma.policyAcknowledgment.findFirst({
where: { policyId: id, userId: currentUser.id },
orderBy: { version: 'desc' },
});
userAcknowledgment = ack
? {
acknowledged: true,
acknowledgedAt: ack.acknowledgedAt,
acknowledgedVersion: ack.version,
needsReAcknowledgment: ack.version < policy.version,
}
: { acknowledged: false, needsReAcknowledgment: policy.requiresAcknowledgment };
}
return {
...policy,
acknowledgmentStats,
userAcknowledgment,
};
}
async update(id: string, dto: UpdatePolicyDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can update policies');
}
const policy = await this.prisma.policy.findUnique({ where: { id } });
if (!policy) throw new NotFoundException('Policy not found');
const updateData: any = {};
let isNewVersion = false;
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.requiresAcknowledgment !== undefined) updateData.requiresAcknowledgment = dto.requiresAcknowledgment;
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
if (dto.category !== undefined) updateData.category = dto.category;
// Content change = new version
if (dto.content !== undefined && dto.content !== policy.content) {
updateData.content = dto.content;
updateData.version = policy.version + 1;
updateData.publishedAt = new Date();
isNewVersion = true;
// Store previous version
await this.prisma.policyVersion.create({
data: {
policyId: policy.id,
version: policy.version,
content: policy.content,
publishedAt: policy.publishedAt,
},
});
}
const updated = await this.prisma.policy.update({
where: { id },
data: updateData,
});
// If new version and requires acknowledgment, notify all contractors
if (isNewVersion && updated.requiresAcknowledgment) {
await this.sendPolicyNotifications(updated);
}
this.logger.log(
`Policy "${updated.name}" updated${isNewVersion ? ` to v${updated.version}` : ''} by ${currentUser.email}`,
);
return updated;
}
async acknowledge(policyId: string, currentUser: RequestUser): Promise<any> {
const policy = await this.prisma.policy.findUnique({ where: { id: policyId } });
if (!policy) throw new NotFoundException('Policy not found');
if (!policy.isActive) {
throw new BadRequestException('Cannot acknowledge an inactive policy');
}
// Check if already acknowledged at current version
const existing = await this.prisma.policyAcknowledgment.findFirst({
where: {
policyId,
userId: currentUser.id,
version: policy.version,
},
});
if (existing) {
return { message: 'Already acknowledged', acknowledgedAt: existing.acknowledgedAt };
}
const ack = await this.prisma.policyAcknowledgment.create({
data: {
policyId,
userId: currentUser.id,
version: policy.version,
acknowledgedAt: new Date(),
},
});
this.logger.log(
`Policy "${policy.name}" v${policy.version} acknowledged by ${currentUser.email}`,
);
return {
message: 'Policy acknowledged',
acknowledgedAt: ack.acknowledgedAt,
version: ack.version,
};
}
async getAcknowledgmentStatus(policyId: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view acknowledgment status');
}
const policy = await this.prisma.policy.findUnique({ where: { id: policyId } });
if (!policy) throw new NotFoundException('Policy not found');
const acks = await this.prisma.policyAcknowledgment.findMany({
where: { policyId, version: policy.version },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
const totalContractors = await this.prisma.user.count({
where: { role: 'CONTRACTOR', status: { in: ['ACTIVE', 'ON_PIP'] }, deletedAt: null },
});
return {
policyName: policy.name,
version: policy.version,
totalContractors,
acknowledged: acks.length,
pending: totalContractors - acks.length,
details: acks.map((a: any) => ({ user: a.user, acknowledgedAt: a.acknowledgedAt })),
};
}
async getPreviousVersions(policyId: string, currentUser: RequestUser): Promise<any[]> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can view policy version history');
}
const policy = await this.prisma.policy.findUnique({ where: { id: policyId } });
if (!policy) throw new NotFoundException('Policy not found');
const versions = await this.prisma.policyVersion.findMany({
where: { policyId },
orderBy: { version: 'desc' },
});
return [
{ version: policy.version, content: policy.content, publishedAt: policy.publishedAt, isCurrent: true },
...versions.map((v: any) => ({ version: v.version, content: v.content, publishedAt: v.publishedAt, isCurrent: false })),
];
}
async archive(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can archive policies');
}
const policy = await this.prisma.policy.findUnique({ where: { id } });
if (!policy) throw new NotFoundException('Policy not found');
return this.prisma.policy.update({
where: { id },
data: { isActive: false },
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete policies');
}
const policy = await this.prisma.policy.findUnique({ where: { id } });
if (!policy) throw new NotFoundException('Policy not found');
// Check if any acknowledgments exist
const ackCount = await this.prisma.policyAcknowledgment.count({ where: { policyId: id } });
if (ackCount > 0) {
throw new BadRequestException(
`This policy has been acknowledged by ${ackCount} contractor(s). It cannot be deleted — archive it instead.`,
);
}
// Delete versions first
await this.prisma.policyVersion.deleteMany({ where: { policyId: id } });
await this.prisma.policy.delete({ where: { id } });
this.logger.log(`Policy ${id} deleted by ${currentUser.email}`);
}
async getUnacknowledgedPolicies(userId: string): Promise<any[]> {
const activePolicies = await this.prisma.policy.findMany({
where: { isActive: true, requiresAcknowledgment: true },
});
const unacknowledged: any[] = [];
for (const policy of activePolicies) {
const ack = await this.prisma.policyAcknowledgment.findFirst({
where: {
policyId: policy.id,
userId,
version: policy.version,
},
});
if (!ack) {
unacknowledged.push(policy);
}
}
return unacknowledged;
}
private async sendPolicyNotifications(policy: any): Promise<void> {
const contractors = await this.prisma.user.findMany({
where: { role: 'CONTRACTOR', status: { in: ['ACTIVE', 'ON_PIP'] }, deletedAt: null },
select: { id: true },
});
for (const contractor of contractors) {
try {
await this.notificationsService.create({
userId: contractor.id,
type: 'BLOCKING',
category: 'POLICY',
title: `Policy Update: ${policy.name}`,
message: `A ${policy.version > 1 ? 'new version of' : 'new'} policy "${policy.name}" requires your acknowledgment. Please review and acknowledge.`,
actionUrl: `/policies/${policy.id}`,
isBlocking: true,
entityType: 'policy',
entityId: policy.id,
});
} catch (err) {
this.logger.warn(`Failed to send policy notification to ${contractor.id}: ${err.message}`);
}
}
}
}
\ No newline at end of file
// ─── OFFBOARDING & DOCUMENT MODELS ─────────────────────────
model Termination {
id String @id @default(uuid())
userId String
user User @relation("UserTerminations", fields: [userId], references: [id], onDelete: RESTRICT)
terminationType String // VOLUNTARY, FOR_CAUSE, MUTUAL_AGREEMENT, CONTRACT_EXPIRY
reason String
effectiveDate DateTime
noticeDate DateTime
notes String?
status String @default("INITIATED") // INITIATED, CHECKLIST_COMPLETE, COMPLETED, CANCELLED
initiatedById String
initiatedBy User @relation("TerminationInitiator", fields: [initiatedById], references: [id], onDelete: RESTRICT)
completedAt DateTime?
completedById String?
checklist OffboardingChecklistItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([effectiveDate])
}
model OffboardingChecklistItem {
id String @id @default(uuid())
terminationId String
termination Termination @relation(fields: [terminationId], references: [id], onDelete: CASCADE)
title String
verifiedBy String // SYSTEM, SUPER_ADMIN, ADMIN, PROJECT_LEADER
position Int
isCompleted Boolean @default(false)
completedAt DateTime?
completedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([terminationId])
}
model Policy {
id String @id @default(uuid())
name String
content String
version Int @default(1)
category String @default("GENERAL")
requiresAcknowledgment Boolean @default(true)
isActive Boolean @default(true)
publishedAt DateTime?
createdById String
createdBy User @relation("PolicyCreator", fields: [createdById], references: [id], onDelete: RESTRICT)
acknowledgments PolicyAcknowledgment[]
versions PolicyVersion[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([category])
}
model PolicyVersion {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id], onDelete: CASCADE)
version Int
content String
publishedAt DateTime?
createdAt DateTime @default(now())
@@index([policyId])
}
model PolicyAcknowledgment {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id], onDelete: CASCADE)
userId String
user User @relation("PolicyAcknowledgments", fields: [userId], references: [id], onDelete: CASCADE)
version Int
acknowledgedAt DateTime @default(now())
@@unique([policyId, userId, version])
@@index([policyId])
@@index([userId])
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment