Commit dbe7775f authored by Administrator's avatar Administrator

Update 28 files via Son of Anton

parent 0ff1fc23
...@@ -41,6 +41,11 @@ import { NoticesModule } from './modules/notices/notices.module'; ...@@ -41,6 +41,11 @@ import { NoticesModule } from './modules/notices/notices.module';
// ─── Phase 1F: Background Jobs ────────────────────────────── // ─── Phase 1F: Background Jobs ──────────────────────────────
import { JobsModule } from './jobs/jobs.module'; import { JobsModule } from './jobs/jobs.module';
// ─── Phase 2A: Evaluation & Performance ─────────────────────
import { EvaluationsModule } from './modules/evaluations/evaluations.module';
import { PIPModule } from './modules/pip/pip.module';
import { LearningModule } from './modules/learning/learning.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';
...@@ -83,6 +88,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware'; ...@@ -83,6 +88,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
NoticesModule, NoticesModule,
// Phase 1F // Phase 1F
JobsModule, JobsModule,
// Phase 2A
EvaluationsModule,
PIPModule,
LearningModule,
], ],
providers: [ providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
......
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export interface SystemMetrics {
daysReported: number;
expectedDays: number;
onTimeRate: number;
tasksCompleted: number;
tasksAssigned: number;
deadlineHitRate: number;
totalDeductionCount: number;
totalDeductionAmountPiasters: number;
totalBountyCount: number;
totalBountyAmountPiasters: number;
avgDailyHoursReported: number;
currentStreak: number;
bestStreak: number;
messagesSent: number;
}
@Injectable()
export class AutoMetricsService {
private readonly logger = new Logger(AutoMetricsService.name);
constructor(private readonly prisma: PrismaService) {}
async calculate(userId: string, month: number, year: number): Promise<SystemMetrics> {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0, 23, 59, 59, 999);
// ─── Tasks Completed & Assigned ─────────────────────────
let tasksCompleted = 0;
let tasksAssigned = 0;
let deadlinedTasksCompleted = 0;
let deadlinedTasksOnTime = 0;
try {
// Cards assigned to user during this month
tasksAssigned = await this.prisma.card.count({
where: {
assignees: { some: { id: userId } },
createdAt: { lte: endDate },
deletedAt: null,
},
});
// Cards completed (moved to Done) during this month
tasksCompleted = await this.prisma.card.count({
where: {
assignees: { some: { id: userId } },
completedAt: { gte: startDate, lte: endDate },
deletedAt: null,
},
});
// Deadline compliance: cards completed that had deadlines
const completedWithDeadline = await this.prisma.card.findMany({
where: {
assignees: { some: { id: userId } },
completedAt: { gte: startDate, lte: endDate },
dueDate: { not: null },
deletedAt: null,
},
select: { completedAt: true, dueDate: true },
});
deadlinedTasksCompleted = completedWithDeadline.length;
deadlinedTasksOnTime = completedWithDeadline.filter(
(c) => c.completedAt && c.dueDate && new Date(c.completedAt) <= new Date(c.dueDate),
).length;
} catch (err) {
this.logger.warn(`Failed to calculate task metrics for ${userId}: ${err.message}`);
}
// ─── Reports ────────────────────────────────────────────
let daysReported = 0;
let expectedDays = 0;
let onTimeReports = 0;
try {
// Get user's schedule to calculate expected days
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { weeklySchedule: true },
});
if (user?.weeklySchedule) {
const schedule = user.weeklySchedule as Record<string, string>;
const { getScheduledDaysOfWeek, getWorkingDaysInMonth } = await import('../../common/utils/date.util');
const scheduledDays = getScheduledDaysOfWeek(schedule);
expectedDays = getWorkingDaysInMonth(year, month, scheduledDays);
}
// Count reports using DailyReport model (Phase 2D)
// For now, this will be 0 until Phase 2D is built
try {
const dailyReportModel = (this.prisma as any).dailyReport;
if (dailyReportModel && typeof dailyReportModel.count === 'function') {
daysReported = await dailyReportModel.count({
where: {
userId,
reportDate: { gte: startDate, lte: endDate },
status: { not: 'DRAFT' },
},
});
onTimeReports = await dailyReportModel.count({
where: {
userId,
reportDate: { gte: startDate, lte: endDate },
status: { in: ['SUBMITTED', 'APPROVED', 'AUTO_APPROVED'] },
},
});
}
} catch {
// DailyReport model doesn't exist yet (Phase 2D)
}
} catch (err) {
this.logger.warn(`Failed to calculate report metrics for ${userId}: ${err.message}`);
}
// ─── Deductions ─────────────────────────────────────────
let totalDeductionCount = 0;
let totalDeductionAmountPiasters = 0;
try {
const deductions = await this.prisma.deduction.findMany({
where: {
userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
select: { appliedAmountPiasters: true },
});
totalDeductionCount = deductions.length;
totalDeductionAmountPiasters = deductions.reduce(
(sum, d) => sum + (d.appliedAmountPiasters || 0),
0,
);
} catch (err) {
this.logger.warn(`Failed to calculate deduction metrics: ${err.message}`);
}
// ─── Bounties ───────────────────────────────────────────
let totalBountyCount = 0;
let totalBountyAmountPiasters = 0;
try {
const bounties = await this.prisma.bountyPayout.findMany({
where: {
userId,
payrollMonth: month,
payrollYear: year,
revokedAt: null,
},
select: { amountPiasters: true },
});
totalBountyCount = bounties.length;
totalBountyAmountPiasters = bounties.reduce((sum, b) => sum + b.amountPiasters, 0);
} catch (err) {
this.logger.warn(`Failed to calculate bounty metrics: ${err.message}`);
}
// ─── Messages Sent ──────────────────────────────────────
let messagesSent = 0;
try {
messagesSent = await this.prisma.message.count({
where: {
senderId: userId,
createdAt: { gte: startDate, lte: endDate },
deletedAt: null,
},
});
} catch (err) {
this.logger.warn(`Failed to count messages: ${err.message}`);
}
// ─── Compile Results ────────────────────────────────────
const onTimeRate = daysReported > 0 ? Math.round((onTimeReports / daysReported) * 100) : 0;
const deadlineHitRate =
deadlinedTasksCompleted > 0
? Math.round((deadlinedTasksOnTime / deadlinedTasksCompleted) * 100)
: 100; // No deadlined tasks = 100% compliance
return {
daysReported,
expectedDays,
onTimeRate,
tasksCompleted,
tasksAssigned,
deadlineHitRate,
totalDeductionCount,
totalDeductionAmountPiasters,
totalBountyCount,
totalBountyAmountPiasters,
avgDailyHoursReported: 0, // Phase 2D
currentStreak: 0, // Requires Reports module
bestStreak: 0, // Requires Reports module
messagesSent,
};
}
/**
* Auto-calculate the Technical evaluation scores:
* - Task Completion Rate: (Cards Done / Cards Assigned) × 5
* - Deadline Compliance: (Cards on time / Cards with deadline completed) × 5
*/
calculateAutoTechnicalScores(metrics: SystemMetrics): {
taskCompletion: number;
deadlineCompliance: number;
} {
const taskCompletion =
metrics.tasksAssigned > 0
? Math.round((metrics.tasksCompleted / metrics.tasksAssigned) * 5 * 10) / 10
: 5.0;
const deadlineCompliance = Math.round((metrics.deadlineHitRate / 100) * 5 * 10) / 10;
return {
taskCompletion: Math.min(5, Math.max(1, taskCompletion)),
deadlineCompliance: Math.min(5, Math.max(1, deadlineCompliance)),
};
}
/**
* Auto-calculate the Professional evaluation scores:
* - Reporting Compliance: (On-time reports / Expected) × 5
* - Policy Compliance: 5 - (violations × 0.5), min 1
*/
calculateAutoProfessionalScores(metrics: SystemMetrics): {
reportingCompliance: number;
policyCompliance: number;
} {
const reportingCompliance =
metrics.expectedDays > 0
? Math.round((metrics.daysReported / metrics.expectedDays) * 5 * 10) / 10
: 5.0;
const policyViolations = metrics.totalDeductionCount; // Each deduction counts as a violation
const policyCompliance = Math.max(1, 5 - policyViolations * 0.5);
return {
reportingCompliance: Math.min(5, Math.max(1, reportingCompliance)),
policyCompliance: Math.round(policyCompliance * 10) / 10,
};
}
}
\ No newline at end of file
import { IsString, IsInt, IsOptional, Min, Max } from 'class-validator';
export class CreateEvaluationDto {
@IsString()
userId: string;
@IsInt()
@Min(1)
@Max(12)
month: number;
@IsInt()
@Min(2020)
year: number;
}
\ No newline at end of file
import { IsOptional, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class EvaluationFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
month?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
year?: number;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
rating?: string;
}
\ No newline at end of file
export class EvaluationResponseDto {
id: string;
userId: string;
month: number;
year: number;
technicalScore: number | null;
professionalScore: number | null;
overallScore: number | null;
rating: string | null;
status: string;
systemMetrics: any;
acknowledgedAt: string | null;
responseText: string | null;
compiledAt: string | null;
createdAt: string;
}
\ No newline at end of file
import { IsNumber, IsString, IsOptional, Min, Max, MinLength } from 'class-validator';
export class SubmitProfessionalEvalDto {
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
profReportingCompliance?: number; // Override auto-calculated
@IsNumber()
@Min(1)
@Max(5)
profCommunication: number;
@IsString()
@MinLength(20)
profCommunicationNotes: string;
@IsNumber()
@Min(1)
@Max(5)
profCollaboration: number;
@IsString()
@MinLength(20)
profCollaborationNotes: string;
@IsNumber()
@Min(1)
@Max(5)
profReliability: number;
@IsString()
@MinLength(20)
profReliabilityNotes: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
profPolicyCompliance?: number; // Override auto-calculated
@IsOptional()
@IsString()
@MinLength(30)
profOverrideJustification?: string;
}
\ No newline at end of file
import { IsNumber, IsString, IsOptional, Min, Max, MinLength } from 'class-validator';
export class SubmitTechnicalEvalDto {
@IsNumber()
@Min(1)
@Max(5)
techCodeQuality: number;
@IsString()
@MinLength(20)
techCodeQualityNotes: string;
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
techTaskCompletion?: number; // Override auto-calculated
@IsOptional()
@IsNumber()
@Min(1)
@Max(5)
techDeadlineCompliance?: number; // Override auto-calculated
@IsNumber()
@Min(1)
@Max(5)
techGrowth: number;
@IsString()
@MinLength(20)
techGrowthNotes: string;
@IsNumber()
@Min(1)
@Max(5)
techProblemSolving: number;
@IsString()
@MinLength(20)
techProblemSolvingNotes: string;
@IsOptional()
@IsString()
@MinLength(30)
techOverrideJustification?: string; // Required if overriding auto values
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { EvaluationsService } from './evaluations.service';
import { CreateEvaluationDto } from './dto/create-evaluation.dto';
import { SubmitTechnicalEvalDto } from './dto/technical-eval.dto';
import { SubmitProfessionalEvalDto } from './dto/professional-eval.dto';
import { EvaluationFilterDto } from './dto/evaluation-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('evaluations')
export class EvaluationsController {
constructor(private readonly evaluationsService: EvaluationsService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreateEvaluationDto, @CurrentUser() user: RequestUser) {
return this.evaluationsService.create(dto, user);
}
@Post('bulk')
@Roles('SUPER_ADMIN', 'ADMIN')
async createBulk(
@Body() body: { month: number; year: number },
@CurrentUser() user: RequestUser,
) {
return this.evaluationsService.createBulkForMonth(body.month, body.year, user);
}
@Get()
async findAll(@Query() filter: EvaluationFilterDto, @CurrentUser() user: RequestUser) {
return this.evaluationsService.findAll(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.evaluationsService.findById(id, user);
}
@Post(':id/technical')
@Roles('SUPER_ADMIN', 'TEAM_LEAD')
@HttpCode(HttpStatus.OK)
async submitTechnical(
@Param('id') id: string,
@Body() dto: SubmitTechnicalEvalDto,
@CurrentUser() user: RequestUser,
) {
return this.evaluationsService.submitTechnical(id, dto, user);
}
@Post(':id/professional')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async submitProfessional(
@Param('id') id: string,
@Body() dto: SubmitProfessionalEvalDto,
@CurrentUser() user: RequestUser,
) {
return this.evaluationsService.submitProfessional(id, dto, user);
}
@Post(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.evaluationsService.acknowledge(id, user);
}
@Post(':id/respond')
@HttpCode(HttpStatus.OK)
async respond(
@Param('id') id: string,
@Body('responseText') responseText: string,
@CurrentUser() user: RequestUser,
) {
return this.evaluationsService.respond(id, responseText, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async update(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
return this.evaluationsService.update(id, data, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.evaluationsService.delete(id, user);
return { message: 'Evaluation deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { EvaluationsController } from './evaluations.controller';
import { EvaluationsService } from './evaluations.service';
import { AutoMetricsService } from './auto-metrics.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [EvaluationsController],
providers: [EvaluationsService, AutoMetricsService],
exports: [EvaluationsService, AutoMetricsService],
})
export class EvaluationsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { AutoMetricsService } from './auto-metrics.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CreateEvaluationDto } from './dto/create-evaluation.dto';
import { SubmitTechnicalEvalDto } from './dto/technical-eval.dto';
import { SubmitProfessionalEvalDto } from './dto/professional-eval.dto';
import { EvaluationFilterDto } from './dto/evaluation-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class EvaluationsService {
private readonly logger = new Logger(EvaluationsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly autoMetrics: AutoMetricsService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreateEvaluationDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create evaluations');
}
const user = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!user) throw new NotFoundException('Contractor not found');
const existing = await this.prisma.evaluation.findUnique({
where: { userId_month_year: { userId: dto.userId, month: dto.month, year: dto.year } },
});
if (existing) throw new ConflictException(`Evaluation already exists for ${dto.month}/${dto.year}`);
// Calculate system metrics
const metrics = await this.autoMetrics.calculate(dto.userId, dto.month, dto.year);
const evaluation = await this.prisma.evaluation.create({
data: {
userId: dto.userId,
month: dto.month,
year: dto.year,
systemMetrics: metrics as any,
status: 'PENDING_TECHNICAL',
},
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
});
this.logger.log(
`Evaluation created for ${user.firstName} ${user.lastName}${dto.month}/${dto.year} by ${currentUser.email}`,
);
return evaluation;
}
async createBulkForMonth(month: number, year: number, currentUser: RequestUser): Promise<{ created: number; skipped: number }> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can bulk-create evaluations');
}
const activeContractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
},
select: { id: true },
});
let created = 0;
let skipped = 0;
for (const contractor of activeContractors) {
try {
const existing = await this.prisma.evaluation.findUnique({
where: { userId_month_year: { userId: contractor.id, month, year } },
});
if (existing) {
skipped++;
continue;
}
const metrics = await this.autoMetrics.calculate(contractor.id, month, year);
await this.prisma.evaluation.create({
data: {
userId: contractor.id,
month,
year,
systemMetrics: metrics as any,
status: 'PENDING_TECHNICAL',
},
});
created++;
} catch (err) {
this.logger.warn(`Failed to create evaluation for ${contractor.id}: ${err.message}`);
skipped++;
}
}
this.logger.log(`Bulk evaluation creation for ${month}/${year}: ${created} created, ${skipped} skipped`);
return { created, skipped };
}
async submitTechnical(
evaluationId: string,
dto: SubmitTechnicalEvalDto,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'TEAM_LEAD') {
throw new ForbiddenException('Only Super Admin and Project Leaders can submit technical evaluations');
}
const evaluation = await this.prisma.evaluation.findUnique({
where: { id: evaluationId },
include: { user: { select: { id: true, assignedProjectLeaderId: true } } },
});
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (evaluation.status !== 'PENDING_TECHNICAL' && currentUser.role !== 'SUPER_ADMIN') {
throw new BadRequestException('This evaluation is not pending technical review');
}
// PL can only evaluate their own team
if (currentUser.role === 'TEAM_LEAD') {
if (evaluation.user.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only evaluate contractors on your team');
}
}
// Get auto-calculated values
const metrics = evaluation.systemMetrics as any;
const autoScores = this.autoMetrics.calculateAutoTechnicalScores(metrics || {
tasksCompleted: 0, tasksAssigned: 0, deadlineHitRate: 100,
});
// Check if overrides require justification
const hasOverrides =
(dto.techTaskCompletion !== undefined && dto.techTaskCompletion !== autoScores.taskCompletion) ||
(dto.techDeadlineCompliance !== undefined && dto.techDeadlineCompliance !== autoScores.deadlineCompliance);
if (hasOverrides && !dto.techOverrideJustification) {
throw new BadRequestException('Override justification is required when changing auto-calculated values');
}
const techTaskCompletion = dto.techTaskCompletion ?? autoScores.taskCompletion;
const techDeadlineCompliance = dto.techDeadlineCompliance ?? autoScores.deadlineCompliance;
// Calculate weighted technical score
// Code Quality: 25%, Task Completion: 25%, Deadline: 20%, Growth: 15%, Problem Solving: 15%
const technicalScore =
dto.techCodeQuality * 0.25 +
techTaskCompletion * 0.25 +
techDeadlineCompliance * 0.20 +
dto.techGrowth * 0.15 +
dto.techProblemSolving * 0.15;
const updated = await this.prisma.evaluation.update({
where: { id: evaluationId },
data: {
techCodeQuality: dto.techCodeQuality,
techCodeQualityNotes: dto.techCodeQualityNotes,
techTaskCompletion,
techTaskCompletionAuto: autoScores.taskCompletion,
techDeadlineCompliance,
techDeadlineComplianceAuto: autoScores.deadlineCompliance,
techGrowth: dto.techGrowth,
techGrowthNotes: dto.techGrowthNotes,
techProblemSolving: dto.techProblemSolving,
techProblemSolvingNotes: dto.techProblemSolvingNotes,
techOverrideJustification: dto.techOverrideJustification || null,
technicalScore: Math.round(technicalScore * 100) / 100,
technicalSubmittedById: currentUser.id,
technicalSubmittedAt: new Date(),
status: 'PENDING_PROFESSIONAL',
},
});
this.logger.log(
`Technical evaluation submitted for eval ${evaluationId}: score ${technicalScore.toFixed(2)} by ${currentUser.email}`,
);
// Notify admins that technical is done
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const admin of admins) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'EVALUATION',
title: `Technical Evaluation Submitted`,
message: `Technical evaluation for ${evaluation.user?.firstName || 'contractor'} (${evaluation.month}/${evaluation.year}) has been submitted. Professional evaluation pending.`,
actionUrl: `/admin/evaluations`,
entityType: 'evaluation',
entityId: evaluationId,
});
} catch { /* non-critical */ }
}
return updated;
}
async submitProfessional(
evaluationId: string,
dto: SubmitProfessionalEvalDto,
currentUser: RequestUser,
): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can submit professional evaluations');
}
const evaluation = await this.prisma.evaluation.findUnique({
where: { id: evaluationId },
});
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (evaluation.status !== 'PENDING_PROFESSIONAL' && currentUser.role !== 'SUPER_ADMIN') {
throw new BadRequestException('Technical evaluation must be submitted first');
}
const metrics = evaluation.systemMetrics as any;
const autoScores = this.autoMetrics.calculateAutoProfessionalScores(metrics || {
daysReported: 0, expectedDays: 0, totalDeductionCount: 0,
});
const hasOverrides =
(dto.profReportingCompliance !== undefined && dto.profReportingCompliance !== autoScores.reportingCompliance) ||
(dto.profPolicyCompliance !== undefined && dto.profPolicyCompliance !== autoScores.policyCompliance);
if (hasOverrides && !dto.profOverrideJustification) {
throw new BadRequestException('Override justification is required when changing auto-calculated values');
}
const profReportingCompliance = dto.profReportingCompliance ?? autoScores.reportingCompliance;
const profPolicyCompliance = dto.profPolicyCompliance ?? autoScores.policyCompliance;
// Weighted: Reporting 25%, Communication 25%, Collaboration 20%, Reliability 20%, Policy 10%
const professionalScore =
profReportingCompliance * 0.25 +
dto.profCommunication * 0.25 +
dto.profCollaboration * 0.20 +
dto.profReliability * 0.20 +
profPolicyCompliance * 0.10;
const updated = await this.prisma.evaluation.update({
where: { id: evaluationId },
data: {
profReportingCompliance,
profReportingComplianceAuto: autoScores.reportingCompliance,
profCommunication: dto.profCommunication,
profCommunicationNotes: dto.profCommunicationNotes,
profCollaboration: dto.profCollaboration,
profCollaborationNotes: dto.profCollaborationNotes,
profReliability: dto.profReliability,
profReliabilityNotes: dto.profReliabilityNotes,
profPolicyCompliance,
profPolicyComplianceAuto: autoScores.policyCompliance,
profOverrideJustification: dto.profOverrideJustification || null,
professionalScore: Math.round(professionalScore * 100) / 100,
professionalSubmittedById: currentUser.id,
professionalSubmittedAt: new Date(),
},
});
this.logger.log(
`Professional evaluation submitted for eval ${evaluationId}: score ${professionalScore.toFixed(2)} by ${currentUser.email}`,
);
// Auto-compile
return this.compile(evaluationId, currentUser);
}
async compile(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 } },
},
});
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (!evaluation.technicalScore || !evaluation.professionalScore) {
throw new BadRequestException('Both technical and professional evaluations must be submitted before compilation');
}
const overallScore = evaluation.technicalScore * 0.5 + evaluation.professionalScore * 0.5;
const roundedScore = Math.round(overallScore * 100) / 100;
let rating: string;
if (roundedScore >= 4.5) rating = 'EXCEPTIONAL';
else if (roundedScore >= 3.5) rating = 'STRONG';
else if (roundedScore >= 2.5) rating = 'ADEQUATE';
else if (roundedScore >= 1.5) rating = 'BELOW_EXPECTATIONS';
else rating = 'UNACCEPTABLE';
const compiled = await this.prisma.evaluation.update({
where: { id: evaluationId },
data: {
overallScore: roundedScore,
rating,
compiledAt: new Date(),
compiledById: currentUser.id,
status: 'COMPILED',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
// Send blocking notification to contractor
try {
await this.notificationsService.create({
userId: evaluation.userId,
type: 'BLOCKING',
category: 'EVALUATION',
title: `Monthly Evaluation: ${evaluation.month}/${evaluation.year}`,
message: `Your monthly evaluation has been compiled. Overall Score: ${roundedScore.toFixed(2)} — Rating: ${rating}. Please review and acknowledge.`,
actionUrl: `/evaluations/${evaluationId}`,
isBlocking: true,
entityType: 'evaluation',
entityId: evaluationId,
});
} catch (err) {
this.logger.warn(`Failed to send evaluation notification: ${err.message}`);
}
// Check if PIP should be triggered
if (roundedScore < 2.5) {
this.logger.warn(
`⚠️ LOW EVALUATION: ${evaluation.user?.firstName} ${evaluation.user?.lastName} scored ${roundedScore} (${rating}). PIP recommendation triggered.`,
);
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const admin of admins) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'EVALUATION',
title: `PIP Recommended: ${evaluation.user?.firstName} ${evaluation.user?.lastName}`,
message: `Contractor scored ${roundedScore.toFixed(2)} (${rating}) on their ${evaluation.month}/${evaluation.year} evaluation. ${roundedScore < 1.5 ? 'Immediate termination review required.' : 'PIP creation recommended.'}`,
actionUrl: `/admin/evaluations`,
entityType: 'evaluation',
entityId: evaluationId,
});
} catch { /* non-critical */ }
}
}
this.logger.log(
`Evaluation ${evaluationId} compiled: ${roundedScore.toFixed(2)} (${rating})`,
);
return compiled;
}
async acknowledge(evaluationId: string, currentUser: RequestUser): Promise<any> {
const evaluation = await this.prisma.evaluation.findUnique({ where: { id: evaluationId } });
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (evaluation.userId !== currentUser.id) {
throw new ForbiddenException('You can only acknowledge your own evaluations');
}
if (evaluation.status !== 'COMPILED') {
throw new BadRequestException('This evaluation is not ready for acknowledgment');
}
return this.prisma.evaluation.update({
where: { id: evaluationId },
data: {
acknowledgedAt: new Date(),
status: 'ACKNOWLEDGED',
},
});
}
async respond(evaluationId: string, responseText: string, currentUser: RequestUser): Promise<any> {
if (!responseText || responseText.length < 20) {
throw new BadRequestException('Response must be at least 20 characters');
}
const evaluation = await this.prisma.evaluation.findUnique({ where: { id: evaluationId } });
if (!evaluation) throw new NotFoundException('Evaluation not found');
if (evaluation.userId !== currentUser.id) {
throw new ForbiddenException('You can only respond to your own evaluations');
}
if (evaluation.status !== 'ACKNOWLEDGED') {
throw new BadRequestException('You must acknowledge the evaluation before responding');
}
return this.prisma.evaluation.update({
where: { id: evaluationId },
data: {
responseText,
respondedAt: new Date(),
status: 'RESPONDED',
},
});
}
async findAll(filter: EvaluationFilterDto, 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;
} else if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
if (filter.userId) where.userId = filter.userId;
if (filter.month) where.month = filter.month;
if (filter.year) where.year = filter.year;
if (filter.status) where.status = filter.status;
if (filter.rating) where.rating = filter.rating;
const [data, total] = await Promise.all([
this.prisma.evaluation.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: [{ year: 'desc' }, { month: 'desc' }],
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
},
}),
this.prisma.evaluation.count({ where }),
]);
// Strip financial data for PLs
const sanitized = data.map((e: any) => {
if (currentUser.role === 'TEAM_LEAD') {
const { systemMetrics, ...rest } = e;
const metrics = systemMetrics as any;
return {
...rest,
systemMetrics: metrics ? {
...metrics,
totalDeductionAmountPiasters: undefined,
totalBountyAmountPiasters: undefined,
} : null,
};
}
return e;
});
return buildPaginatedResponse(sanitized, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const evaluation = await this.prisma.evaluation.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: 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');
}
if (currentUser.role === 'TEAM_LEAD') {
const contractor = await this.prisma.user.findUnique({
where: { id: evaluation.userId },
select: { assignedProjectLeaderId: true },
});
if (contractor?.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only view evaluations for your team');
}
}
return evaluation;
}
async update(id: string, data: any, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit compiled evaluations');
}
const evaluation = await this.prisma.evaluation.findUnique({ where: { id } });
if (!evaluation) throw new NotFoundException('Evaluation not found');
const updateData: any = {};
const editableFields = [
'techCodeQuality', 'techTaskCompletion', 'techDeadlineCompliance', 'techGrowth', 'techProblemSolving',
'profReportingCompliance', 'profCommunication', 'profCollaboration', 'profReliability', 'profPolicyCompliance',
'overallScore', 'rating', 'status',
];
for (const field of editableFields) {
if (data[field] !== undefined) updateData[field] = data[field];
}
// Recalculate scores if individual scores changed
if (updateData.techCodeQuality !== undefined || updateData.techTaskCompletion !== undefined) {
const tech = { ...evaluation, ...updateData };
updateData.technicalScore =
(tech.techCodeQuality || 0) * 0.25 +
(tech.techTaskCompletion || 0) * 0.25 +
(tech.techDeadlineCompliance || 0) * 0.20 +
(tech.techGrowth || 0) * 0.15 +
(tech.techProblemSolving || 0) * 0.15;
}
if (updateData.profReportingCompliance !== undefined || updateData.profCommunication !== undefined) {
const prof = { ...evaluation, ...updateData };
updateData.professionalScore =
(prof.profReportingCompliance || 0) * 0.25 +
(prof.profCommunication || 0) * 0.25 +
(prof.profCollaboration || 0) * 0.20 +
(prof.profReliability || 0) * 0.20 +
(prof.profPolicyCompliance || 0) * 0.10;
}
if (updateData.technicalScore !== undefined || updateData.professionalScore !== undefined) {
const techScore = updateData.technicalScore ?? evaluation.technicalScore ?? 0;
const profScore = updateData.professionalScore ?? evaluation.professionalScore ?? 0;
updateData.overallScore = Math.round((techScore * 0.5 + profScore * 0.5) * 100) / 100;
}
return this.prisma.evaluation.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 evaluations');
}
const evaluation = await this.prisma.evaluation.findUnique({ where: { id } });
if (!evaluation) throw new NotFoundException('Evaluation not found');
await this.prisma.evaluation.delete({ where: { id } });
this.logger.log(`Evaluation ${id} deleted by ${currentUser.email}`);
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCompetencyAreaDto, UpdateCompetencyAreaDto } from './dto/competency-area.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class CompetencyService {
private readonly logger = new Logger(CompetencyService.name);
constructor(private readonly prisma: PrismaService) {}
async findAllAreas(): Promise<any[]> {
return this.prisma.competencyArea.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
}
async findAllAreasAdmin(): Promise<any[]> {
return this.prisma.competencyArea.findMany({
orderBy: { order: 'asc' },
});
}
async createArea(dto: CreateCompetencyAreaDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage competency areas');
}
const existing = await this.prisma.competencyArea.findUnique({ where: { name: dto.name } });
if (existing) throw new ConflictException(`Competency area "${dto.name}" already exists`);
const maxOrder = await this.prisma.competencyArea.aggregate({ _max: { order: true } });
return this.prisma.competencyArea.create({
data: {
name: dto.name,
description: dto.description || null,
order: dto.order ?? ((maxOrder._max?.order || 0) + 1),
},
});
}
async updateArea(id: string, dto: UpdateCompetencyAreaDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage competency areas');
}
const area = await this.prisma.competencyArea.findUnique({ where: { id } });
if (!area) throw new NotFoundException('Competency area not found');
const updateData: any = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.order !== undefined) updateData.order = dto.order;
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
return this.prisma.competencyArea.update({ where: { id }, data: updateData });
}
async deleteArea(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can manage competency areas');
}
const area = await this.prisma.competencyArea.findUnique({ where: { id } });
if (!area) throw new NotFoundException('Competency area not found');
// Soft-deactivate instead of deleting (ratings reference this)
await this.prisma.competencyArea.update({
where: { id },
data: { isActive: false },
});
}
async getRadarChartData(userId: string): Promise<any> {
const areas = await this.prisma.competencyArea.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
const ratings = await this.prisma.competencyRating.findMany({
where: { userId },
include: { competencyArea: { select: { name: true } } },
});
const selfRatings: Record<string, number> = {};
const plRatings: Record<string, number> = {};
for (const rating of ratings) {
if (rating.type === 'SELF') {
selfRatings[rating.competencyAreaId] = rating.level;
} else if (rating.type === 'PL_ASSESSMENT') {
plRatings[rating.competencyAreaId] = rating.level;
}
}
return {
areas: areas.map((area) => ({
id: area.id,
name: area.name,
selfLevel: selfRatings[area.id] ?? null,
plLevel: plRatings[area.id] ?? null,
})),
lastSelfAssessment: ratings.find((r) => r.type === 'SELF')?.assessedAt || null,
lastPLAssessment: ratings.find((r) => r.type === 'PL_ASSESSMENT')?.assessedAt || null,
};
}
async updateRating(
userId: string,
competencyAreaId: string,
type: string,
level: number,
assessedById: string,
notes?: string,
): Promise<any> {
return this.prisma.competencyRating.upsert({
where: {
userId_competencyAreaId_type: { userId, competencyAreaId, type },
},
update: {
level,
assessedById,
assessedAt: new Date(),
notes: notes || null,
},
create: {
userId,
competencyAreaId,
type,
level,
assessedById,
assessedAt: new Date(),
notes: notes || null,
},
});
}
}
\ No newline at end of file
import { IsString, MinLength } from 'class-validator';
export class AssessLearningGoalDto {
@IsString()
result: string; // PASSED, FAILED
@IsString()
@MinLength(20)
assessmentNotes: string;
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, IsBoolean, MinLength, MaxLength, Min } from 'class-validator';
export class CreateCompetencyAreaDto {
@IsString()
@MinLength(3)
@MaxLength(200)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsInt()
@Min(0)
order?: number;
}
export class UpdateCompetencyAreaDto {
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsInt()
@Min(0)
order?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
\ No newline at end of file
import { IsString, IsDateString, IsOptional, MinLength, MaxLength } from 'class-validator';
export class CreateLearningGoalDto {
@IsString()
userId: string;
@IsString()
@MinLength(1)
@MaxLength(100)
title: string;
@IsString()
@MinLength(50)
description: string;
@IsString()
competencyAreaId: string;
@IsDateString()
deadline: string;
@IsString()
assessmentMethod: string; // PL_ASSESSMENT, LIVE_DEMONSTRATION, DELIVERABLE_REVIEW, QUIZ_TEST
@IsString()
@MinLength(50)
passFailCriteria: string;
@IsOptional()
@IsString()
source?: string; // MANUAL, SELF_ASSESSMENT_GAP, PIP, EVALUATION
}
\ No newline at end of file
import { IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class LearningGoalFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
competencyAreaId?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
source?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { LearningService } from './learning.service';
import { CompetencyService } from './competency.service';
import { CreateLearningGoalDto } from './dto/create-learning-goal.dto';
import { AssessLearningGoalDto } from './dto/assess-learning-goal.dto';
import { LearningGoalFilterDto } from './dto/learning-goal-filter.dto';
import { CreateCompetencyAreaDto, UpdateCompetencyAreaDto } from './dto/competency-area.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('learning')
export class LearningController {
constructor(
private readonly learningService: LearningService,
private readonly competencyService: CompetencyService,
) {}
// ─── LEARNING GOALS ──────────────────────────────────
@Post('goals')
async createGoal(@Body() dto: CreateLearningGoalDto, @CurrentUser() user: RequestUser) {
return this.learningService.create(dto, user);
}
@Get('goals')
async findAllGoals(@Query() filter: LearningGoalFilterDto, @CurrentUser() user: RequestUser) {
return this.learningService.findAll(filter, user);
}
@Get('goals/:id')
async findGoalById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.learningService.findById(id, user);
}
@Put('goals/:id')
async updateGoal(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
return this.learningService.update(id, data, user);
}
@Post('goals/:id/assess')
@HttpCode(HttpStatus.OK)
async assessGoal(
@Param('id') id: string,
@Body() dto: AssessLearningGoalDto,
@CurrentUser() user: RequestUser,
) {
return this.learningService.assess(id, dto, user);
}
@Post('goals/:id/extend')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async extendGoal(
@Param('id') id: string,
@Body() body: { deadline: string; reason: string },
@CurrentUser() user: RequestUser,
) {
return this.learningService.extend(id, body.deadline, body.reason, user);
}
@Delete('goals/:id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deleteGoal(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.learningService.delete(id, user);
return { message: 'Learning goal deleted' };
}
// ─── COMPETENCY AREAS ────────────────────────────────
@Get('competency-areas')
async findAllAreas(@CurrentUser() user: RequestUser) {
if (user.role === 'SUPER_ADMIN') {
return this.competencyService.findAllAreasAdmin();
}
return this.competencyService.findAllAreas();
}
@Post('competency-areas')
@Roles('SUPER_ADMIN')
async createArea(@Body() dto: CreateCompetencyAreaDto, @CurrentUser() user: RequestUser) {
return this.competencyService.createArea(dto, user);
}
@Put('competency-areas/:id')
@Roles('SUPER_ADMIN')
async updateArea(
@Param('id') id: string,
@Body() dto: UpdateCompetencyAreaDto,
@CurrentUser() user: RequestUser,
) {
return this.competencyService.updateArea(id, dto, user);
}
@Delete('competency-areas/:id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deleteArea(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.competencyService.deleteArea(id, user);
return { message: 'Competency area deactivated' };
}
// ─── COMPETENCY PROFILE (RADAR CHART) ────────────────
@Get('profile/:userId')
async getRadarChart(@Param('userId') userId: string, @CurrentUser() user: RequestUser) {
// Contractors can only see their own
if (user.role === 'CONTRACTOR' && userId !== user.id) {
return { areas: [] };
}
return this.competencyService.getRadarChartData(userId);
}
@Get('profile')
async getMyRadarChart(@CurrentUser() user: RequestUser) {
return this.competencyService.getRadarChartData(user.id);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { LearningController } from './learning.controller';
import { LearningService } from './learning.service';
import { CompetencyService } from './competency.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [LearningController],
providers: [LearningService, CompetencyService],
exports: [LearningService, CompetencyService],
})
export class LearningModule {}
\ 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 { CompetencyService } from './competency.service';
import { CreateLearningGoalDto } from './dto/create-learning-goal.dto';
import { AssessLearningGoalDto } from './dto/assess-learning-goal.dto';
import { LearningGoalFilterDto } from './dto/learning-goal-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class LearningService {
private readonly logger = new Logger(LearningService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
private readonly competencyService: CompetencyService,
) {}
async create(dto: CreateLearningGoalDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot create learning goals');
}
// PLs can only create for their team
if (currentUser.role === 'TEAM_LEAD') {
const contractor = await this.prisma.user.findUnique({
where: { id: dto.userId },
select: { assignedProjectLeaderId: true },
});
if (contractor?.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only create learning goals for your team');
}
}
const user = await this.prisma.user.findFirst({ where: { id: dto.userId, deletedAt: null } });
if (!user) throw new NotFoundException('Contractor not found');
const area = await this.prisma.competencyArea.findUnique({ where: { id: dto.competencyAreaId } });
if (!area) throw new NotFoundException('Competency area not found');
const validMethods = ['PL_ASSESSMENT', 'LIVE_DEMONSTRATION', 'DELIVERABLE_REVIEW', 'QUIZ_TEST'];
if (!validMethods.includes(dto.assessmentMethod)) {
throw new BadRequestException(`Assessment method must be one of: ${validMethods.join(', ')}`);
}
const deadline = new Date(dto.deadline);
const goal = await this.prisma.learningGoal.create({
data: {
userId: dto.userId,
createdById: currentUser.id,
title: dto.title,
description: dto.description,
competencyAreaId: dto.competencyAreaId,
deadline,
originalDeadline: deadline,
assessmentMethod: dto.assessmentMethod,
passFailCriteria: dto.passFailCriteria,
source: dto.source || 'MANUAL',
status: 'ACTIVE',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
competencyArea: { select: { id: true, name: true } },
},
});
// Notify contractor
try {
await this.notificationsService.create({
userId: dto.userId,
type: 'IMPORTANT',
category: 'LEARNING',
title: `New Learning Goal: ${dto.title}`,
message: `A new learning goal has been assigned: "${dto.title}" in ${area.name}. Deadline: ${deadline.toISOString().split('T')[0]}.`,
actionUrl: '/learning',
entityType: 'learningGoal',
entityId: goal.id,
});
} catch { /* non-critical */ }
this.logger.log(`Learning goal "${dto.title}" created for ${user.firstName} by ${currentUser.email}`);
return goal;
}
async createFromSelfAssessmentGaps(userId: string): Promise<number> {
const ratings = await this.prisma.competencyRating.findMany({
where: { userId, type: 'SELF', level: { lte: 1 } },
include: { competencyArea: true },
});
let created = 0;
const deadline = new Date();
deadline.setDate(deadline.getDate() + 45);
for (const rating of ratings) {
if (!rating.competencyArea.isActive) continue;
// Check if a goal already exists for this area
const existing = await this.prisma.learningGoal.findFirst({
where: {
userId,
competencyAreaId: rating.competencyAreaId,
source: 'SELF_ASSESSMENT_GAP',
status: { in: ['ACTIVE', 'OVERDUE', 'EXTENDED'] },
},
});
if (existing) continue;
await this.prisma.learningGoal.create({
data: {
userId,
createdById: userId, // System-created, attributed to contractor
title: `Learn: ${rating.competencyArea.name}`,
description: `Self-assessment identified this as a learning gap (rated ${rating.level}/5). Per contract: "Any skill you don't yet have, you agree to acquire within 45 days."`,
competencyAreaId: rating.competencyAreaId,
deadline,
originalDeadline: deadline,
assessmentMethod: 'PL_ASSESSMENT',
passFailCriteria: `Demonstrate competency level 3 or above in ${rating.competencyArea.name} as assessed by your Project Leader.`,
source: 'SELF_ASSESSMENT_GAP',
status: 'ACTIVE',
},
});
created++;
}
if (created > 0) {
this.logger.log(`${created} learning goals auto-created from self-assessment gaps for user ${userId}`);
}
return created;
}
async findAll(filter: LearningGoalFilterDto, 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;
} else if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
if (filter.userId) where.userId = filter.userId;
if (filter.competencyAreaId) where.competencyAreaId = filter.competencyAreaId;
if (filter.status) where.status = filter.status;
if (filter.source) where.source = filter.source;
const [data, total] = await Promise.all([
this.prisma.learningGoal.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { deadline: 'asc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
competencyArea: { select: { id: true, name: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.learningGoal.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'asc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const goal = await this.prisma.learningGoal.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
competencyArea: { select: { id: true, name: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!goal) throw new NotFoundException('Learning goal not found');
if (currentUser.role === 'CONTRACTOR' && goal.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own learning goals');
}
return goal;
}
async assess(id: string, dto: AssessLearningGoalDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot assess learning goals');
}
if (!['PASSED', 'FAILED'].includes(dto.result)) {
throw new BadRequestException('Result must be PASSED or FAILED');
}
const goal = await this.prisma.learningGoal.findUnique({
where: { id },
include: {
user: { select: { id: true, assignedProjectLeaderId: true } },
competencyArea: { select: { id: true, name: true } },
},
});
if (!goal) throw new NotFoundException('Learning goal not found');
if (currentUser.role === 'TEAM_LEAD' && goal.user.assignedProjectLeaderId !== currentUser.id) {
throw new ForbiddenException('You can only assess learning goals for your team');
}
const updated = await this.prisma.learningGoal.update({
where: { id },
data: {
assessedById: currentUser.id,
assessedAt: new Date(),
assessmentResult: dto.result,
assessmentNotes: dto.assessmentNotes,
status: dto.result,
},
});
// If passed, update competency radar chart
if (dto.result === 'PASSED') {
try {
await this.competencyService.updateRating(
goal.userId,
goal.competencyAreaId,
'PL_ASSESSMENT',
3, // Minimum "Competent" level
currentUser.id,
`Passed learning goal: ${goal.title}`,
);
} catch (err) {
this.logger.warn(`Failed to update competency rating: ${err.message}`);
}
try {
await this.notificationsService.create({
userId: goal.userId,
type: 'IMPORTANT',
category: 'LEARNING',
title: `Learning Goal Passed: ${goal.title}`,
message: `Congratulations! You passed the assessment for "${goal.title}" in ${goal.competencyArea.name}.`,
entityType: 'learningGoal',
entityId: id,
});
} catch { /* non-critical */ }
} else {
// FAILED
try {
await this.notificationsService.create({
userId: goal.userId,
type: 'IMPORTANT',
category: 'LEARNING',
title: `Learning Goal Assessment: Not Passed — ${goal.title}`,
message: `Your assessment for "${goal.title}" did not pass. Notes: ${dto.assessmentNotes.substring(0, 100)}. Your manager may extend the deadline.`,
entityType: 'learningGoal',
entityId: id,
});
} catch { /* non-critical */ }
// Check if this is the second failure — escalation
if (goal.extensionCount >= 1) {
this.logger.warn(`⚠️ ESCALATION: ${goal.userId} failed learning goal "${goal.title}" for the 2nd time`);
const admins = await this.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const admin of admins) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'LEARNING',
title: `Learning Goal Escalation: Second Failure`,
message: `Contractor failed "${goal.title}" for the second time. Per the 45/90-day rule, this is grounds for termination review.`,
entityType: 'learningGoal',
entityId: id,
});
} catch { /* non-critical */ }
}
}
}
this.logger.log(`Learning goal ${id} assessed: ${dto.result} by ${currentUser.email}`);
return updated;
}
async extend(id: string, newDeadline: string, reason: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can extend learning goal deadlines');
}
if (!reason || reason.length < 20) {
throw new BadRequestException('Extension reason must be at least 20 characters');
}
const goal = await this.prisma.learningGoal.findUnique({ where: { id } });
if (!goal) throw new NotFoundException('Learning goal not found');
// Check 90-day rule: deadline cannot exceed 2× the original deadline duration
const originalDuration = goal.originalDeadline.getTime() - goal.createdAt.getTime();
const maxDeadline = new Date(goal.createdAt.getTime() + originalDuration * 2);
const proposedDeadline = new Date(newDeadline);
if (proposedDeadline > maxDeadline) {
throw new BadRequestException(
`Extended deadline cannot exceed ${maxDeadline.toISOString().split('T')[0]} (double the original deadline). Per the 45/90-day rule.`,
);
}
return this.prisma.learningGoal.update({
where: { id },
data: {
deadline: proposedDeadline,
extendedAt: new Date(),
extendedById: currentUser.id,
extensionReason: reason,
extensionCount: { increment: 1 },
status: 'EXTENDED',
},
});
}
async update(id: string, data: any, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
// PLs can edit goals they created
if (currentUser.role === 'TEAM_LEAD') {
const goal = await this.prisma.learningGoal.findUnique({ where: { id } });
if (!goal || goal.createdById !== currentUser.id) {
throw new ForbiddenException('You can only edit learning goals you created');
}
} else {
throw new ForbiddenException('Insufficient permissions to edit learning goals');
}
}
const goal = await this.prisma.learningGoal.findUnique({ where: { id } });
if (!goal) throw new NotFoundException('Learning goal not found');
const updateData: any = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.passFailCriteria !== undefined) updateData.passFailCriteria = data.passFailCriteria;
if (data.assessmentMethod !== undefined) updateData.assessmentMethod = data.assessmentMethod;
if (data.deadline !== undefined) updateData.deadline = new Date(data.deadline);
if (data.status !== undefined) updateData.status = data.status;
return this.prisma.learningGoal.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 learning goals');
}
const goal = await this.prisma.learningGoal.findUnique({ where: { id } });
if (!goal) throw new NotFoundException('Learning goal not found');
await this.prisma.learningGoal.delete({ where: { id } });
this.logger.log(`Learning goal ${id} deleted by ${currentUser.email}`);
}
}
\ No newline at end of file
import { IsString, IsInt, IsDateString, IsArray, IsOptional, Min, Max, MinLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class PIPIssueDto {
@IsInt()
number: number;
@IsString()
@MinLength(50)
text: string;
}
export class PIPTargetDto {
@IsInt()
number: number;
@IsString()
@MinLength(30)
text: string;
@IsString()
@MinLength(10)
measurableTarget: string;
}
export class CreatePIPDto {
@IsString()
userId: string;
@IsInt()
@Min(30)
@Max(60)
duration: number; // 30, 45, or 60
@IsDateString()
startDate: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => PIPIssueDto)
specificIssues: PIPIssueDto[]; // min 3
@IsArray()
@ValidateNested({ each: true })
@Type(() => PIPTargetDto)
improvementTargets: PIPTargetDto[]; // min 3
@IsString()
checkInSchedule: string; // WEEKLY, BIWEEKLY
@IsString()
checkInDay: string; // SUNDAY, MONDAY, etc.
@IsString()
@MinLength(100)
successCriteria: string;
@IsOptional()
@IsString()
consequenceOfFailure?: string;
@IsOptional()
@IsString()
triggerType?: string;
@IsOptional()
@IsString()
triggerEntityId?: string;
}
\ No newline at end of file
import { IsString, IsArray, IsOptional, IsDateString, MinLength } from 'class-validator';
export class PIPCheckInDto {
@IsOptional()
@IsArray()
attendees?: string[]; // User IDs
@IsString()
@MinLength(30)
summary: string;
@IsOptional()
@IsString()
progressNotes?: string;
@IsOptional()
@IsArray()
actionItems?: Array<{ text: string; assignee?: string; dueDate?: string }>;
}
\ No newline at end of file
import { IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class PIPFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
status?: string;
}
\ No newline at end of file
import { IsString, MinLength } from 'class-validator';
export class PIPResultDto {
@IsString()
result: string; // PASSED, FAILED
@IsString()
@MinLength(50)
resultNotes: string;
}
\ No newline at end of file
import { IsString, IsInt, IsOptional, IsDateString, IsArray, Min, Max, MinLength } from 'class-validator';
export class UpdatePIPDto {
@IsOptional()
@IsInt()
@Min(30)
@Max(60)
duration?: number;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsArray()
specificIssues?: any[];
@IsOptional()
@IsArray()
improvementTargets?: any[];
@IsOptional()
@IsString()
successCriteria?: string;
@IsOptional()
@IsString()
consequenceOfFailure?: string;
@IsOptional()
@IsString()
checkInSchedule?: string;
@IsOptional()
@IsString()
checkInDay?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { PIPService } from './pip.service';
import { CreatePIPDto } from './dto/create-pip.dto';
import { UpdatePIPDto } from './dto/update-pip.dto';
import { PIPCheckInDto } from './dto/pip-checkin.dto';
import { PIPResultDto } from './dto/pip-result.dto';
import { PIPFilterDto } from './dto/pip-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('pips')
export class PIPController {
constructor(private readonly pipService: PIPService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreatePIPDto, @CurrentUser() user: RequestUser) {
return this.pipService.create(dto, user);
}
@Get()
async findAll(@Query() filter: PIPFilterDto, @CurrentUser() user: RequestUser) {
return this.pipService.findAll(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.pipService.findById(id, user);
}
@Put(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
async update(@Param('id') id: string, @Body() dto: UpdatePIPDto, @CurrentUser() user: RequestUser) {
return this.pipService.update(id, dto, user);
}
@Post(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.pipService.acknowledge(id, user);
}
@Post(':id/checkins/:checkInId')
@HttpCode(HttpStatus.OK)
async logCheckIn(
@Param('id') id: string,
@Param('checkInId') checkInId: string,
@Body() dto: PIPCheckInDto,
@CurrentUser() user: RequestUser,
) {
return this.pipService.logCheckIn(id, checkInId, dto, user);
}
@Post(':id/result')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async recordResult(@Param('id') id: string, @Body() dto: PIPResultDto, @CurrentUser() user: RequestUser) {
return this.pipService.recordResult(id, dto, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.pipService.delete(id, user);
return { message: 'PIP deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PIPController } from './pip.controller';
import { PIPService } from './pip.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [PIPController],
providers: [PIPService],
exports: [PIPService],
})
export class PIPModule {}
\ 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 { CreatePIPDto } from './dto/create-pip.dto';
import { UpdatePIPDto } from './dto/update-pip.dto';
import { PIPCheckInDto } from './dto/pip-checkin.dto';
import { PIPResultDto } from './dto/pip-result.dto';
import { PIPFilterDto } from './dto/pip-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class PIPService {
private readonly logger = new Logger(PIPService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async create(dto: CreatePIPDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create PIPs');
}
if (dto.specificIssues.length < 3) {
throw new BadRequestException('At least 3 specific issues are required');
}
if (dto.improvementTargets.length < 3) {
throw new BadRequestException('At least 3 improvement targets are required');
}
const contractor = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!contractor) throw new NotFoundException('Contractor not found');
// Check for existing active PIP
const existingPIP = await this.prisma.pIP.findFirst({
where: { userId: dto.userId, status: 'ACTIVE' },
});
if (existingPIP) {
throw new BadRequestException('This contractor already has an active PIP');
}
const startDate = new Date(dto.startDate);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + dto.duration);
const pip = await this.prisma.pIP.create({
data: {
userId: dto.userId,
createdById: currentUser.id,
duration: dto.duration,
startDate,
endDate,
specificIssues: dto.specificIssues as any,
improvementTargets: dto.improvementTargets as any,
checkInSchedule: dto.checkInSchedule,
checkInDay: dto.checkInDay,
successCriteria: dto.successCriteria,
consequenceOfFailure: dto.consequenceOfFailure || 'Termination of engagement.',
triggerType: dto.triggerType || 'MANUAL',
triggerEntityId: dto.triggerEntityId || null,
status: 'ACTIVE',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
// Generate check-in schedule
await this.generateCheckInSchedule(pip.id, startDate, endDate, dto.checkInSchedule, dto.checkInDay);
// Update contractor status to ON_PIP
await this.prisma.user.update({
where: { id: dto.userId },
data: { status: 'ON_PIP' },
});
// Send blocking notification to contractor
try {
await this.notificationsService.create({
userId: dto.userId,
type: 'BLOCKING',
category: 'PIP',
title: 'Performance Improvement Plan Issued',
message: `A ${dto.duration}-day Performance Improvement Plan has been issued. Start: ${startDate.toISOString().split('T')[0]}. End: ${endDate.toISOString().split('T')[0]}. Please review all details carefully.`,
actionUrl: `/evaluations`,
isBlocking: true,
entityType: 'pip',
entityId: pip.id,
});
} catch (err) {
this.logger.warn(`Failed to send PIP notification: ${err.message}`);
}
this.logger.log(
`PIP created for ${contractor.firstName} ${contractor.lastName}: ${dto.duration} days, by ${currentUser.email}`,
);
return pip;
}
private async generateCheckInSchedule(
pipId: string,
startDate: Date,
endDate: Date,
schedule: string,
dayOfWeek: string,
): Promise<void> {
const dayMap: Record<string, number> = {
SUNDAY: 0, MONDAY: 1, TUESDAY: 2, WEDNESDAY: 3,
THURSDAY: 4, FRIDAY: 5, SATURDAY: 6,
};
const targetDay = dayMap[dayOfWeek] ?? 1;
const intervalDays = schedule === 'BIWEEKLY' ? 14 : 7;
// Find the first check-in day after start date
const current = new Date(startDate);
while (current.getDay() !== targetDay) {
current.setDate(current.getDate() + 1);
}
// Skip to at least one week after start
if (current <= startDate) {
current.setDate(current.getDate() + 7);
}
while (current <= endDate) {
await this.prisma.pIPCheckIn.create({
data: {
pipId,
scheduledDate: new Date(current),
},
});
current.setDate(current.getDate() + intervalDays);
}
}
async findAll(filter: PIPFilterDto, 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;
} else if (currentUser.role === 'TEAM_LEAD') {
where.user = { assignedProjectLeaderId: currentUser.id };
}
if (filter.userId) where.userId = filter.userId;
if (filter.status) where.status = filter.status;
const [data, total] = await Promise.all([
this.prisma.pIP.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
checkIns: { orderBy: { scheduledDate: 'asc' } },
},
}),
this.prisma.pIP.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const pip = await this.prisma.pIP.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
checkIns: {
orderBy: { scheduledDate: 'asc' },
},
},
});
if (!pip) throw new NotFoundException('PIP not found');
if (currentUser.role === 'CONTRACTOR' && pip.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own PIP');
}
return pip;
}
async update(id: string, dto: UpdatePIPDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can edit PIPs');
}
const pip = await this.prisma.pIP.findUnique({ where: { id } });
if (!pip) throw new NotFoundException('PIP not found');
const updateData: any = {};
if (dto.duration !== undefined) updateData.duration = dto.duration;
if (dto.endDate !== undefined) updateData.endDate = new Date(dto.endDate);
if (dto.specificIssues !== undefined) updateData.specificIssues = dto.specificIssues;
if (dto.improvementTargets !== undefined) updateData.improvementTargets = dto.improvementTargets;
if (dto.successCriteria !== undefined) updateData.successCriteria = dto.successCriteria;
if (dto.consequenceOfFailure !== undefined) updateData.consequenceOfFailure = dto.consequenceOfFailure;
if (dto.checkInSchedule !== undefined) updateData.checkInSchedule = dto.checkInSchedule;
if (dto.checkInDay !== undefined) updateData.checkInDay = dto.checkInDay;
return this.prisma.pIP.update({ where: { id }, data: updateData });
}
async logCheckIn(pipId: string, checkInId: string, dto: PIPCheckInDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role === 'CONTRACTOR') {
throw new ForbiddenException('Contractors cannot log PIP check-in notes');
}
const checkIn = await this.prisma.pIPCheckIn.findUnique({
where: { id: checkInId },
include: { pip: true },
});
if (!checkIn) throw new NotFoundException('Check-in not found');
if (checkIn.pipId !== pipId) throw new BadRequestException('Check-in does not belong to this PIP');
return this.prisma.pIPCheckIn.update({
where: { id: checkInId },
data: {
completedAt: new Date(),
loggedById: currentUser.id,
attendees: dto.attendees as any,
summary: dto.summary,
progressNotes: dto.progressNotes || null,
actionItems: dto.actionItems as any || null,
isMissed: false,
},
});
}
async recordResult(id: string, dto: PIPResultDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can record PIP results');
}
if (!['PASSED', 'FAILED'].includes(dto.result)) {
throw new BadRequestException('Result must be PASSED or FAILED');
}
const pip = await this.prisma.pIP.findUnique({
where: { id },
include: { user: { select: { id: true, firstName: true, lastName: true } } },
});
if (!pip) throw new NotFoundException('PIP not found');
if (pip.status !== 'ACTIVE') {
throw new BadRequestException('This PIP is not active');
}
const updated = await this.prisma.pIP.update({
where: { id },
data: {
status: dto.result,
result: dto.result,
resultNotes: dto.resultNotes,
resultDate: new Date(),
resultById: currentUser.id,
},
});
// Update contractor status
if (dto.result === 'PASSED') {
await this.prisma.user.update({
where: { id: pip.userId },
data: { status: 'ACTIVE' },
});
try {
await this.notificationsService.create({
userId: pip.userId,
type: 'IMPORTANT',
category: 'PIP',
title: 'PIP Completed — Passed!',
message: `Congratulations! You have successfully completed your Performance Improvement Plan. Your status has been restored to Active.`,
entityType: 'pip',
entityId: id,
});
} catch { /* non-critical */ }
} else {
// FAILED — notify SA for termination decision
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: 'BLOCKING',
category: 'PIP',
title: `PIP Failed: ${pip.user?.firstName} ${pip.user?.lastName}`,
message: `${pip.user?.firstName} ${pip.user?.lastName} has failed their PIP. Termination review required per the consequence: "${pip.consequenceOfFailure}".`,
isBlocking: true,
entityType: 'pip',
entityId: id,
});
} catch { /* non-critical */ }
}
try {
await this.notificationsService.create({
userId: pip.userId,
type: 'BLOCKING',
category: 'PIP',
title: 'PIP Result: Not Passed',
message: `Your Performance Improvement Plan has concluded with a "Not Passed" result. Management will be in contact regarding next steps.`,
isBlocking: true,
entityType: 'pip',
entityId: id,
});
} catch { /* non-critical */ }
}
this.logger.log(`PIP ${id} result: ${dto.result} by ${currentUser.email}`);
return updated;
}
async acknowledge(id: string, currentUser: RequestUser): Promise<any> {
const pip = await this.prisma.pIP.findUnique({ where: { id } });
if (!pip) throw new NotFoundException('PIP not found');
if (pip.userId !== currentUser.id) {
throw new ForbiddenException('You can only acknowledge your own PIP');
}
return this.prisma.pIP.update({
where: { id },
data: { acknowledgedAt: new Date() },
});
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete PIPs');
}
const pip = await this.prisma.pIP.findUnique({ where: { id } });
if (!pip) throw new NotFoundException('PIP not found');
await this.prisma.pIPCheckIn.deleteMany({ where: { pipId: id } });
await this.prisma.pIP.delete({ where: { id } });
// Restore status if PIP was active
if (pip.status === 'ACTIVE') {
await this.prisma.user.update({
where: { id: pip.userId },
data: { status: 'ACTIVE' },
});
}
this.logger.log(`PIP ${id} deleted by ${currentUser.email}`);
}
}
\ No newline at end of file
// ─── ADD TO User MODEL ────────────────────────────────────────── // ─── COMPETENCY & PERFORMANCE ADDITIONS ──────────────────────────
// These fields must exist on the User model: // Models that extend the core schema for performance tracking
// nameArabic String?
// nationalId String? @unique
// dateOfBirth DateTime?
// phone String? @unique
// phoneSecondary String?
// address String?
// emergencyContactName String?
// emergencyContactPhone String?
// emergencyContactRelationship String?
// bankName String?
// bankAccountNumber String?
// bankAccountHolderName String?
// taxRegistrationNumber String?
// contractorType String?
// department String?
// title String?
// bio String?
// timezone String @default("Africa/Cairo")
// weeklySchedule Json?
// baseSalaryPiasters Int @default(0)
// actualSalaryPiasters Int @default(0)
// startDate DateTime?
// contractStartDate DateTime?
// contractEndDate DateTime?
// assignedProjectLeaderId String?
// onboardingChecklist Json?
// deletedAt DateTime?
// ─── NEW MODELS ───────────────────────────────────────────────── model CompetencyArea {
model Invite {
id String @id @default(uuid())
code String @unique
token String @unique
contractorType String
assignedProjectLeaderId String?
assignedBoardIds String[]
welcomeNote String?
expiresAt DateTime
status String @default("ACTIVE") // ACTIVE, USED, EXPIRED, REVOKED
createdById String
createdBy User @relation("InviteCreator", fields: [createdById], references: [id])
usedById String?
usedBy User? @relation("InviteUsed", fields: [usedById], references: [id])
usedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([code])
@@index([token])
@@index([status])
}
model BoardMember {
id String @id @default(uuid()) id String @id @default(uuid())
boardId String
userId String
role String @default("MEMBER") // OWNER, ADMIN, MEMBER, VIEWER
joinedAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) name String @unique
description String?
@@unique([boardId, userId]) order Int @default(0)
@@index([boardId]) isActive Boolean @default(true)
@@index([userId])
}
model Contract {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contractType String
contractText String
signedAt DateTime
signedFullName String
acknowledgedClauses String[]
signatureIpAddress String
signatureUserAgent String
baseSalaryAtSigning Int
scheduleAtSigning Json?
startDate DateTime
endDate DateTime?
status String @default("ACTIVE") // DRAFT, ACTIVE, EXPIRED, TERMINATED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId]) ratings CompetencyRating[]
@@index([status]) learningGoals LearningGoal[]
} }
model CompetencyRating { model CompetencyRating {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String userId String
competencyAreaId String competencyAreaId String
type String // SELF, PL_ASSESSMENT competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id], onDelete: Cascade)
level Int // 0-5
assessedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) type String // SELF, PL_ASSESSMENT
competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id]) level Int // 0-5
@@unique([userId, competencyAreaId, type]) assessedById String?
@@index([userId]) assessedAt DateTime?
} notes String?
model SalaryChangeLog {
id String @id @default(uuid())
userId String
oldSalaryPiasters Int
newSalaryPiasters Int
reason String
changedById String
createdAt DateTime @default(now())
@@index([userId])
}
model StatusChangeLog {
id String @id @default(uuid())
userId String
fromStatus String
toStatus String
reason String?
changedById String
createdAt DateTime @default(now())
@@index([userId])
}
model PrivateNote {
id String @id @default(uuid())
userId String
content String
authorId String
author User @relation("NoteAuthor", fields: [authorId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId_competencyAreaId_type], name: "userId_competencyAreaId_type")
@@index([userId]) @@index([userId])
@@index([competencyAreaId])
} }
\ No newline at end of file
// ─── EVALUATION & PERFORMANCE MODELS ─────────────────────────────
// Phase 2A: Monthly evaluations, PIPs, learning goals
model Evaluation {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation("UserEvaluations", fields: [userId], references: [id], onDelete: Cascade)
month Int // 1-12
year Int
// Technical Evaluation (by Project Leader)
technicalSubmittedById String?
technicalSubmittedAt DateTime?
techCodeQuality Float? // 1-5
techCodeQualityNotes String?
techTaskCompletion Float? // 1-5 (auto-calculated)
techTaskCompletionAuto Float? // The auto-calculated value before override
techDeadlineCompliance Float? // 1-5 (auto-calculated)
techDeadlineComplianceAuto Float? // The auto-calculated value before override
techGrowth Float? // 1-5
techGrowthNotes String?
techProblemSolving Float? // 1-5
techProblemSolvingNotes String?
techOverrideJustification String? // Required if auto-values are overridden
technicalScore Float? // Weighted average
// Professional Evaluation (by Admin)
professionalSubmittedById String?
professionalSubmittedAt DateTime?
profReportingCompliance Float? // 1-5 (auto-calculated)
profReportingComplianceAuto Float?
profCommunication Float? // 1-5
profCommunicationNotes String?
profCollaboration Float? // 1-5
profCollaborationNotes String?
profReliability Float? // 1-5
profReliabilityNotes String?
profPolicyCompliance Float? // 1-5 (auto-calculated)
profPolicyComplianceAuto Float?
profOverrideJustification String?
professionalScore Float? // Weighted average
// Compiled
overallScore Float? // (technical * 0.5) + (professional * 0.5)
rating String? // EXCEPTIONAL, STRONG, ADEQUATE, BELOW_EXPECTATIONS, UNACCEPTABLE
compiledAt DateTime?
compiledById String?
// Auto-calculated system metrics (supplementary, not scored)
systemMetrics Json? // { daysReported, expectedDays, onTimeRate, tasksCompleted, tasksAssigned, deadlineHitRate, totalDeductions, totalBounties, avgDailyHours, currentStreak, bestStreak, messagesSent }
// Contractor acknowledgment
acknowledgedAt DateTime?
responseText String?
respondedAt DateTime?
status String @default("PENDING_TECHNICAL") // PENDING_TECHNICAL, PENDING_PROFESSIONAL, COMPILED, ACKNOWLEDGED, RESPONDED
@@unique([userId, month, year])
@@index([userId])
@@index([month, year])
@@index([status])
}
model PIP {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation("UserPIPs", fields: [userId], references: [id], onDelete: Cascade)
createdById String
createdBy User @relation("PIPCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
duration Int // 30, 45, or 60 calendar days
startDate DateTime
endDate DateTime
specificIssues Json // Array of { number, text } min 3 items
improvementTargets Json // Array of { number, text, measurableTarget } min 3 items
checkInSchedule String // WEEKLY, BIWEEKLY
checkInDay String // SUNDAY, MONDAY, etc.
successCriteria String // Min 100 chars
consequenceOfFailure String @default("Termination of engagement.")
// Lifecycle
acknowledgedAt DateTime?
status String @default("ACTIVE") // ACTIVE, PASSED, FAILED, CANCELLED
result String? // PASSED, FAILED
resultNotes String?
resultDate DateTime?
resultById String?
// Trigger info
triggerType String? // DEDUCTION_THRESHOLD, LOW_EVALUATION, CONSECUTIVE_LOW_EVAL, DISAPPEARANCE, MANUAL
triggerEntityId String? // e.g., evaluation ID or deduction ID
checkIns PIPCheckIn[]
@@index([userId])
@@index([status])
@@index([startDate, endDate])
}
model PIPCheckIn {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
pipId String
pip PIP @relation(fields: [pipId], references: [id], onDelete: Cascade)
scheduledDate DateTime
completedAt DateTime?
loggedById String?
attendees Json? // Array of user IDs
summary String?
progressNotes String?
actionItems Json? // Array of { text, assignee, dueDate }
isMissed Boolean @default(false)
@@index([pipId])
@@index([scheduledDate])
}
model LearningGoal {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
user User @relation("UserLearningGoals", fields: [userId], references: [id], onDelete: Cascade)
createdById String
createdBy User @relation("LearningGoalCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
title String
description String
competencyAreaId String
competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id], onDelete: Restrict)
deadline DateTime
originalDeadline DateTime // Preserved even if extended
assessmentMethod String // PL_ASSESSMENT, LIVE_DEMONSTRATION, DELIVERABLE_REVIEW, QUIZ_TEST
passFailCriteria String // Min 50 chars
// Assessment
assessedById String?
assessedAt DateTime?
assessmentResult String? // PASSED, FAILED
assessmentNotes String?
// Extension
extendedAt DateTime?
extendedById String?
extensionReason String?
extensionCount Int @default(0)
// Source
source String @default("MANUAL") // MANUAL, SELF_ASSESSMENT_GAP, PIP, EVALUATION
status String @default("ACTIVE") // ACTIVE, OVERDUE, PASSED, FAILED, EXTENDED
@@index([userId])
@@index([competencyAreaId])
@@index([status])
@@index([deadline])
}
\ 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