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';
// ─── Phase 1F: Background Jobs ──────────────────────────────
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 { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -83,6 +88,10 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
NoticesModule,
// Phase 1F
JobsModule,
// Phase 2A
EvaluationsModule,
PIPModule,
LearningModule,
],
providers: [
{ 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
This diff is collapsed.
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
This diff is collapsed.
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
This diff is collapsed.
// ─── ADD TO User MODEL ──────────────────────────────────────────
// These fields must exist on the User model:
// 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?
// ─── COMPETENCY & PERFORMANCE ADDITIONS ──────────────────────────
// Models that extend the core schema for performance tracking
// ─── NEW MODELS ─────────────────────────────────────────────────
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 {
model CompetencyArea {
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())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([boardId, userId])
@@index([boardId])
@@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
name String @unique
description String?
order Int @default(0)
isActive Boolean @default(true)
@@index([userId])
@@index([status])
ratings CompetencyRating[]
learningGoals LearningGoal[]
}
model CompetencyRating {
id String @id @default(uuid())
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
competencyAreaId String
type String // SELF, PL_ASSESSMENT
level Int // 0-5
assessedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
competencyArea CompetencyArea @relation(fields: [competencyAreaId], references: [id])
type String // SELF, PL_ASSESSMENT
level Int // 0-5
@@unique([userId, competencyAreaId, type])
@@index([userId])
}
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
assessedById String?
assessedAt DateTime?
notes String?
@@unique([userId_competencyAreaId_type], name: "userId_competencyAreaId_type")
@@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