Commit 0ff1fc23 authored by Administrator's avatar Administrator

Update 14 files via Son of Anton

parent 31c97f0d
{
"name": "@the-grind/backend",
"version": "1.0.0",
"description": "The Grind HR Platform — Backend API",
"name": "the-grind-backend",
"version": "3.0.0",
"description": "The Grind — AL-Arcade HR Platform v3.0 Backend",
"private": true,
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
......@@ -14,49 +13,52 @@
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node src/prisma/seed.ts",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.3.0",
"@nestjs/swagger": "^7.2.0",
"@nestjs/websockets": "^10.3.0",
"@prisma/client": "^5.8.0",
"bcrypt": "^5.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.0.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-socket.io": "^10.0.0",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.0.0",
"@nestjs/websockets": "^10.0.0",
"@prisma/client": "^5.0.0",
"bcrypt": "^5.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator": "^0.14.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"helmet": "^7.0.0",
"minio": "^7.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.14",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"socket.io": "^4.7.4",
"socket.io": "^4.7.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.0",
"@types/cookie-parser": "^1.4.0",
"@types/express": "^4.17.0",
"@types/multer": "^1.4.0",
"@types/node": "^20.0.0",
"@types/passport-jwt": "^4.0.0",
"@types/uuid": "^9.0.7",
"prisma": "^5.8.0",
"rimraf": "^5.0.5",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
"@types/uuid": "^9.0.0",
"prisma": "^5.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.3.0"
}
}
\ No newline at end of file
......@@ -38,6 +38,9 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { MessagesModule } from './modules/messages/messages.module';
import { NoticesModule } from './modules/notices/notices.module';
// ─── Phase 1F: Background Jobs ──────────────────────────────
import { JobsModule } from './jobs/jobs.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -78,6 +81,8 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
NotificationsModule,
MessagesModule,
NoticesModule,
// Phase 1F
JobsModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface AutoArchiveResult {
archived: number;
boardsProcessed: number;
}
@Injectable()
export class AutoArchiveProcessor {
private readonly logger = new Logger(AutoArchiveProcessor.name);
constructor(private readonly prisma: PrismaService) {}
async process(): Promise<AutoArchiveResult> {
let archived = 0;
let boardsProcessed = 0;
// Get all active boards with their auto-archive setting
const boards = await this.prisma.board.findMany({
where: { deletedAt: null, isArchived: false },
select: { id: true, name: true, autoArchiveDoneCardsDays: true },
});
for (const board of boards) {
const archiveDays = board.autoArchiveDoneCardsDays || 30;
if (archiveDays <= 0) continue;
const cutoffDate = new Date(Date.now() - archiveDays * 24 * 60 * 60 * 1000);
try {
// Find Done columns for this board
const doneColumns = await this.prisma.column.findMany({
where: { boardId: board.id, isDone: true },
select: { id: true },
});
if (doneColumns.length === 0) continue;
const doneColumnIds = doneColumns.map((c) => c.id);
// Find cards in Done that were completed before the cutoff
const cardsToArchive = await this.prisma.card.findMany({
where: {
columnId: { in: doneColumnIds },
completedAt: { lt: cutoffDate },
deletedAt: null,
isArchived: false,
},
select: { id: true, cardNumber: true },
});
if (cardsToArchive.length === 0) continue;
// Archive them in batch
const result = await this.prisma.card.updateMany({
where: {
id: { in: cardsToArchive.map((c) => c.id) },
},
data: {
isArchived: true,
archivedAt: new Date(),
},
});
archived += result.count;
boardsProcessed++;
if (result.count > 0) {
this.logger.log(
`Board "${board.name}": archived ${result.count} cards (older than ${archiveDays} days in Done)`,
);
}
} catch (err) {
this.logger.error(`Error processing board ${board.id} (${board.name}): ${err.message}`);
}
}
return { archived, boardsProcessed };
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
export interface ContractExpiryResult {
notificationsSent: number;
}
@Injectable()
export class ContractExpiryProcessor {
private readonly logger = new Logger(ContractExpiryProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<ContractExpiryResult> {
let notificationsSent = 0;
const now = new Date();
// Check at 30, 60, and 90 day thresholds
const thresholds = [30, 60, 90];
for (const daysOut of thresholds) {
const targetDate = new Date(now);
targetDate.setDate(targetDate.getDate() + daysOut);
const targetStart = new Date(targetDate);
targetStart.setHours(0, 0, 0, 0);
const targetEnd = new Date(targetDate);
targetEnd.setHours(23, 59, 59, 999);
try {
// Find contracts expiring on this target date
const expiringContracts = await this.prisma.contract.findMany({
where: {
endDate: { gte: targetStart, lte: targetEnd },
status: 'ACTIVE',
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
for (const contract of expiringContracts) {
// Check if we already sent a notification for this threshold
const alreadyNotified = await this.prisma.notification.findFirst({
where: {
entityType: 'contract',
entityId: contract.id,
title: { contains: `${daysOut} days` },
createdAt: {
gte: new Date(now.getTime() - 24 * 60 * 60 * 1000), // Within last 24 hours
},
},
});
if (alreadyNotified) continue;
// Notify admins and super admins
const adminUsers = await this.prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'ADMIN'] },
status: 'ACTIVE',
deletedAt: null,
},
select: { id: true },
});
for (const admin of adminUsers) {
try {
await this.notificationsService.create({
userId: admin.id,
type: 'IMPORTANT',
category: 'SYSTEM',
title: `Contract Expiring in ${daysOut} Days: ${contract.user.firstName} ${contract.user.lastName}`,
message: `The contract for ${contract.user.firstName} ${contract.user.lastName} expires on ${contract.endDate?.toISOString().split('T')[0]}. ${daysOut} days remaining. Please review and take action.`,
actionUrl: `/admin/contracts`,
entityType: 'contract',
entityId: contract.id,
});
notificationsSent++;
} catch (err) {
this.logger.warn(`Failed to send contract expiry notification: ${err.message}`);
}
}
// Also notify the contractor at 30 days
if (daysOut === 30) {
try {
await this.notificationsService.create({
userId: contract.userId,
type: 'IMPORTANT',
category: 'SYSTEM',
title: 'Your Contract Expires in 30 Days',
message: `Your contract with AL-Arcade expires on ${contract.endDate?.toISOString().split('T')[0]}. Please speak with your administrator about renewal.`,
entityType: 'contract',
entityId: contract.id,
});
notificationsSent++;
} catch { /* non-critical */ }
}
}
} catch (err) {
this.logger.error(`Error checking ${daysOut}-day contract expiry: ${err.message}`);
}
}
return { notificationsSent };
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
export interface DeadlineScannerResult {
overdueCards: number;
deductionsCreated: number;
deductionsEscalated: number;
}
@Injectable()
export class DeadlineScannerProcessor {
private readonly logger = new Logger(DeadlineScannerProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<DeadlineScannerResult> {
const now = new Date();
let overdueCards = 0;
let deductionsCreated = 0;
let deductionsEscalated = 0;
// Find all cards with deadlines that have passed and are NOT in Done/Archived
const doneColumns = await this.prisma.column.findMany({
where: { type: 'DONE' },
select: { id: true },
});
const doneColumnIds = doneColumns.map((c) => c.id);
const overdueCardsList = await this.prisma.card.findMany({
where: {
dueDate: { lt: now },
completedAt: null,
deletedAt: null,
isArchived: false,
columnId: { notIn: doneColumnIds },
},
include: {
assignees: { select: { id: true, firstName: true, lastName: true } },
column: { select: { boardId: true, name: true, board: { select: { key: true } } } },
},
});
overdueCards = overdueCardsList.length;
for (const card of overdueCardsList) {
if (card.assignees.length === 0) {
// No assignees — can't create a deduction. Log and skip.
continue;
}
const dueDate = new Date(card.dueDate!);
const daysOverdue = Math.ceil((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
// Determine the appropriate sub-category based on days overdue
let subCategory: string;
if (daysOverdue <= 3) subCategory = 'A1';
else if (daysOverdue <= 7) subCategory = 'A2';
else if (daysOverdue <= 14) subCategory = 'A3';
else subCategory = 'A4';
for (const assignee of card.assignees) {
try {
// Check if a deduction draft already exists for this card + user
const existingDeduction = await this.prisma.deduction.findFirst({
where: {
userId: assignee.id,
cardId: card.id,
category: 'A',
status: {
in: [
'PENDING_ADMIN_REVIEW',
'PENDING_ACKNOWLEDGMENT',
'PENDING_RESPONSE',
],
},
},
});
if (existingDeduction) {
// Escalate if the sub-category should be higher
if (this.subCategoryRank(subCategory) > this.subCategoryRank(existingDeduction.subCategory)) {
await this.prisma.deduction.update({
where: { id: existingDeduction.id },
data: {
subCategory,
description: this.buildDescription(card, daysOverdue, subCategory),
},
});
deductionsEscalated++;
this.logger.log(
`Deduction ${existingDeduction.id} escalated from ${existingDeduction.subCategory} to ${subCategory} for card ${card.cardNumber}`,
);
}
} else {
// Check if there's already a resolved deduction for this card+user (upheld/applied)
const resolvedDeduction = await this.prisma.deduction.findFirst({
where: {
userId: assignee.id,
cardId: card.id,
category: 'A',
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
},
});
if (resolvedDeduction) {
// Already handled — don't create another
continue;
}
// Create new deduction draft
const deduction = await this.prisma.deduction.create({
data: {
userId: assignee.id,
category: 'A',
subCategory,
cardId: card.id,
violationDate: dueDate,
description: this.buildDescription(card, daysOverdue, subCategory),
amountPiasters: 0, // Will be calculated when reviewed
originalAmountPiasters: 0,
calculationBasis: `Auto-detected: ${subCategory} deadline violation (${daysOverdue} days overdue)`,
status: 'PENDING_ACKNOWLEDGMENT',
initiatedById: null,
initiatedByRole: 'SYSTEM',
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
});
// Calculate amount using the deduction calculator pattern
try {
const user = await this.prisma.user.findUnique({
where: { id: assignee.id },
select: { actualSalaryPiasters: true, baseSalaryPiasters: true, weeklySchedule: true },
});
if (user) {
const salary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
let amount = 0;
switch (subCategory) {
case 'A1': amount = Math.round(salary * 0.05 * Math.min(daysOverdue, 3) / 30); break;
case 'A2': amount = Math.round(salary * 0.10 * Math.min(daysOverdue, 7) / 30); break;
case 'A3': amount = Math.round(salary * 0.15 * Math.min(daysOverdue, 14) / 30); break;
case 'A4': amount = Math.round(salary * 0.25); break;
}
if (amount > 0) {
await this.prisma.deduction.update({
where: { id: deduction.id },
data: {
amountPiasters: amount,
originalAmountPiasters: amount,
},
});
}
}
} catch (calcErr) {
this.logger.warn(`Failed to calculate deduction amount for ${deduction.id}: ${calcErr.message}`);
}
// Notify the contractor
try {
await this.notificationsService.create({
userId: assignee.id,
type: 'BLOCKING',
category: 'DEDUCTION',
title: `Deadline Violation: ${card.cardNumber}`,
message: `Card "${card.title}" is ${daysOverdue} day(s) overdue. A deduction has been initiated.`,
actionUrl: `/salary`,
isBlocking: true,
entityType: 'deduction',
entityId: deduction.id,
});
} catch (notifErr) {
this.logger.warn(`Failed to send deduction notification: ${notifErr.message}`);
}
deductionsCreated++;
this.logger.log(
`Deduction created: ${subCategory} for ${assignee.firstName} ${assignee.lastName} — card ${card.cardNumber} (${daysOverdue} days overdue)`,
);
}
} catch (err) {
this.logger.error(
`Error processing overdue card ${card.id} for assignee ${assignee.id}: ${err.message}`,
);
}
}
}
return { overdueCards, deductionsCreated, deductionsEscalated };
}
private subCategoryRank(sub: string): number {
const ranks: Record<string, number> = { A1: 1, A2: 2, A3: 3, A4: 4, A5: 5 };
return ranks[sub] || 0;
}
private buildDescription(card: any, daysOverdue: number, subCategory: string): string {
const labels: Record<string, string> = {
A1: 'Slight Delay (1-3 days)',
A2: 'Moderate Delay (4-7 days)',
A3: 'Severe Delay (8-14 days)',
A4: 'Critical Delay (15+ days)',
};
return (
`Auto-detected deadline violation: ${labels[subCategory]}. ` +
`Card "${card.title}" (${card.cardNumber}) on board "${card.column?.board?.key}" ` +
`was due on ${card.dueDate?.toISOString().split('T')[0]} and is now ${daysOverdue} day(s) overdue. ` +
`The card is currently in the "${card.column?.name}" column.`
);
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { HudService } from '../modules/hud/hud.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
export interface DeductionAutoApplyResult {
applied: number;
}
@Injectable()
export class DeductionAutoApplyProcessor {
private readonly logger = new Logger(DeductionAutoApplyProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly hudService: HudService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<DeductionAutoApplyResult> {
let applied = 0;
// Default response window: 48 hours
let responseWindowHours = 48;
try {
const setting = await this.prisma.setting.findUnique({
where: { key: 'deductionResponseWindowHours' },
});
if (setting && typeof setting.value === 'number') {
responseWindowHours = setting.value;
}
} catch {
// Use default
}
const cutoffTime = new Date(Date.now() - responseWindowHours * 60 * 60 * 1000);
// Find deductions that are pending response and the response window has expired
const expiredDeductions = await this.prisma.deduction.findMany({
where: {
status: 'PENDING_RESPONSE',
acknowledgedAt: { not: null, lt: cutoffTime },
respondedAt: null,
autoAppliedAt: null,
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
});
for (const deduction of expiredDeductions) {
try {
await this.prisma.deduction.update({
where: { id: deduction.id },
data: {
status: 'AUTO_APPLIED',
appliedAmountPiasters: deduction.amountPiasters,
appliedAt: new Date(),
autoAppliedAt: new Date(),
reviewDecision: 'UPHELD',
reviewNotes: `Auto-applied: contractor did not respond within the ${responseWindowHours}-hour response window.`,
},
});
// Push HUD update
try {
await this.hudService.pushDeductionApplied(
deduction.userId,
deduction.subCategory,
deduction.amountPiasters,
);
} catch (hudErr) {
this.logger.warn(`Failed to push HUD for auto-applied deduction: ${hudErr.message}`);
}
// Notify the contractor
try {
await this.notificationsService.create({
userId: deduction.userId,
type: 'IMPORTANT',
category: 'DEDUCTION',
title: 'Deduction Auto-Applied',
message: `Deduction (${deduction.subCategory}) of ${deduction.amountPiasters} piasters was auto-applied because no response was submitted within ${responseWindowHours} hours.`,
actionUrl: `/salary`,
entityType: 'deduction',
entityId: deduction.id,
});
} catch (notifErr) {
this.logger.warn(`Failed to send auto-apply notification: ${notifErr.message}`);
}
// Check 40% threshold
await this.checkDeductionThreshold(deduction.userId);
applied++;
this.logger.log(
`Deduction ${deduction.id} auto-applied for ${deduction.user.firstName} ${deduction.user.lastName}: ${deduction.amountPiasters} piasters`,
);
} catch (err) {
this.logger.error(`Failed to auto-apply deduction ${deduction.id}: ${err.message}`);
}
}
// Also check for deductions stuck in PENDING_ACKNOWLEDGMENT for too long (7 days)
const staleAckCutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const staleDeductions = await this.prisma.deduction.findMany({
where: {
status: 'PENDING_ACKNOWLEDGMENT',
createdAt: { lt: staleAckCutoff },
acknowledgedAt: null,
},
select: { id: true, userId: true },
});
for (const stale of staleDeductions) {
try {
// Auto-acknowledge and auto-apply since the contractor is ignoring it
await this.prisma.deduction.update({
where: { id: stale.id },
data: {
acknowledgedAt: new Date(),
acknowledgedById: null,
status: 'AUTO_APPLIED',
appliedAmountPiasters: (await this.prisma.deduction.findUnique({ where: { id: stale.id } }))?.amountPiasters || 0,
appliedAt: new Date(),
autoAppliedAt: new Date(),
reviewDecision: 'UPHELD',
reviewNotes: 'Auto-applied: contractor did not acknowledge within 7 days.',
},
});
try {
await this.hudService.pushHudUpdate(stale.userId);
} catch { /* non-critical */ }
applied++;
this.logger.log(`Stale deduction ${stale.id} auto-applied (7+ days unacknowledged)`);
} catch (err) {
this.logger.error(`Failed to auto-apply stale deduction ${stale.id}: ${err.message}`);
}
}
return { applied };
}
private async checkDeductionThreshold(userId: string): Promise<void> {
try {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { actualSalaryPiasters: true, baseSalaryPiasters: true, firstName: true, lastName: true },
});
if (!user) return;
const actualSalary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
if (actualSalary <= 0) return;
const totalDeductions = await this.prisma.deduction.aggregate({
where: {
userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
_sum: { appliedAmountPiasters: true },
});
const totalAmount = totalDeductions._sum.appliedAmountPiasters || 0;
const percentage = (totalAmount / actualSalary) * 100;
if (percentage >= 40) {
this.logger.warn(
`⚠️ THRESHOLD ALERT: ${user.firstName} ${user.lastName} (${userId}) has reached ${percentage.toFixed(1)}% deduction threshold`,
);
// Notify Super Admin
const superAdmins = await this.prisma.user.findMany({
where: { role: 'SUPER_ADMIN', status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const sa of superAdmins) {
try {
await this.notificationsService.create({
userId: sa.id,
type: 'BLOCKING',
category: 'DEDUCTION',
title: `Critical: ${user.firstName} ${user.lastName} — 40% Deduction Threshold`,
message: `Contractor has reached ${percentage.toFixed(1)}% deduction threshold (${totalAmount} / ${actualSalary} piasters). PIP or termination review required within 5 business days.`,
actionUrl: `/admin/contractors/${userId}`,
isBlocking: true,
entityType: 'user',
entityId: userId,
});
} catch { /* non-critical */ }
}
// Notify the contractor
try {
await this.notificationsService.create({
userId,
type: 'BLOCKING',
category: 'DEDUCTION',
title: 'Critical Deduction Threshold Reached',
message: `Your total deductions this month have reached ${percentage.toFixed(1)}% of your salary. This triggers an automatic performance review.`,
isBlocking: true,
});
} catch { /* non-critical */ }
}
} catch (err) {
this.logger.error(`Failed to check deduction threshold for ${userId}: ${err.message}`);
}
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SchedulerService } from './scheduler.service';
import { UnreportedDayProcessor } from './unreported-day.processor';
import { DeadlineScannerProcessor } from './deadline-scanner.processor';
import { PayrollCalculatorProcessor } from './payroll-calculator.processor';
import { AutoArchiveProcessor } from './auto-archive.processor';
import { ContractExpiryProcessor } from './contract-expiry.processor';
import { DeductionAutoApplyProcessor } from './deduction-auto-apply.processor';
import { RecurringCardProcessor } from './recurring-card.processor';
import { MeetingReminderProcessor } from './meeting-reminder.processor';
import { NotificationCleanupProcessor } from './notification-cleanup.processor';
import { WebhookDispatchProcessor } from './webhook-dispatch.processor';
import { DeductionsModule } from '../modules/deductions/deductions.module';
import { SalaryModule } from '../modules/salary/salary.module';
import { PayrollModule } from '../modules/payroll/payroll.module';
import { HudModule } from '../modules/hud/hud.module';
import { NotificationsModule } from '../modules/notifications/notifications.module';
@Module({
imports: [
ScheduleModule.forRoot(),
DeductionsModule,
SalaryModule,
PayrollModule,
HudModule,
NotificationsModule,
],
providers: [
SchedulerService,
UnreportedDayProcessor,
DeadlineScannerProcessor,
PayrollCalculatorProcessor,
AutoArchiveProcessor,
ContractExpiryProcessor,
DeductionAutoApplyProcessor,
RecurringCardProcessor,
MeetingReminderProcessor,
NotificationCleanupProcessor,
WebhookDispatchProcessor,
],
exports: [SchedulerService],
})
export class JobsModule {}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
export interface MeetingReminderResult {
remindersSent: number;
}
/**
* Meeting Reminder Processor
*
* Sends reminders for upcoming meetings:
* - 1 hour before: Important notification
* - 1 day before: Informational notification
*
* The Meeting model is built in Phase 2C (Time & Scheduling).
* Until Phase 2C is complete, this processor gracefully skips execution.
*/
@Injectable()
export class MeetingReminderProcessor {
private readonly logger = new Logger(MeetingReminderProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<MeetingReminderResult> {
let remindersSent = 0;
try {
// Check if Meeting model exists
const meetingModel = (this.prisma as any).meeting;
if (!meetingModel || typeof meetingModel.findMany !== 'function') {
this.logger.debug('Meeting model not available yet (Phase 2C). Skipping.');
return { remindersSent: 0 };
}
const now = new Date();
// ─── 1-Hour Reminders ────────────────────────────────────
const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000);
const thirtyMinFromNow = new Date(now.getTime() + 30 * 60 * 1000);
const meetingsIn1Hour = await meetingModel.findMany({
where: {
startTime: { gte: thirtyMinFromNow, lte: oneHourFromNow },
status: 'SCHEDULED',
reminderSent1h: { not: true },
},
include: {
invitees: { select: { userId: true } },
},
});
for (const meeting of meetingsIn1Hour) {
for (const invitee of meeting.invitees || []) {
try {
await this.notificationsService.create({
userId: invitee.userId,
type: 'IMPORTANT',
category: 'MEETING',
title: `Meeting in ~1 hour: ${meeting.title}`,
message: `Your meeting "${meeting.title}" starts at ${meeting.startTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}.${meeting.location ? ` Location: ${meeting.location}` : ''}`,
actionUrl: '/meetings',
entityType: 'meeting',
entityId: meeting.id,
});
remindersSent++;
} catch { /* non-critical */ }
}
// Mark reminder as sent
await meetingModel.update({
where: { id: meeting.id },
data: { reminderSent1h: true },
});
}
// ─── 1-Day Reminders ─────────────────────────────────────
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const twentyThreeHoursFromNow = new Date(now.getTime() + 23 * 60 * 60 * 1000);
const meetingsIn1Day = await meetingModel.findMany({
where: {
startTime: { gte: twentyThreeHoursFromNow, lte: oneDayFromNow },
status: 'SCHEDULED',
reminderSent24h: { not: true },
},
include: {
invitees: { select: { userId: true } },
},
});
for (const meeting of meetingsIn1Day) {
for (const invitee of meeting.invitees || []) {
try {
await this.notificationsService.create({
userId: invitee.userId,
type: 'INFORMATIONAL',
category: 'MEETING',
title: `Meeting Tomorrow: ${meeting.title}`,
message: `Reminder: "${meeting.title}" is scheduled for tomorrow at ${meeting.startTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}.`,
actionUrl: '/meetings',
entityType: 'meeting',
entityId: meeting.id,
});
remindersSent++;
} catch { /* non-critical */ }
}
await meetingModel.update({
where: { id: meeting.id },
data: { reminderSent24h: true },
});
}
} catch (err) {
if (err.message?.includes('does not exist') || err.message?.includes('not found') || err.code === 'P2021') {
this.logger.debug('Meeting table does not exist yet. Skipping reminders.');
} else {
this.logger.error(`Meeting reminder processor error: ${err.message}`);
}
}
return { remindersSent };
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface NotificationCleanupResult {
deleted: number;
}
@Injectable()
export class NotificationCleanupProcessor {
private readonly logger = new Logger(NotificationCleanupProcessor.name);
constructor(private readonly prisma: PrismaService) {}
async process(): Promise<NotificationCleanupResult> {
let deleted = 0;
try {
// Delete read, non-blocking notifications older than 90 days
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const result = await this.prisma.notification.deleteMany({
where: {
isRead: true,
isBlocking: false,
createdAt: { lt: ninetyDaysAgo },
},
});
deleted += result.count;
// Delete read, acknowledged blocking notifications older than 1 year
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const blockingResult = await this.prisma.notification.deleteMany({
where: {
isBlocking: true,
acknowledgedAt: { not: null },
createdAt: { lt: oneYearAgo },
},
});
deleted += blockingResult.count;
// Clean up expired sessions (older than 30 days and already revoked or expired)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
try {
const sessionsResult = await this.prisma.session.deleteMany({
where: {
OR: [
{ revokedAt: { not: null }, createdAt: { lt: thirtyDaysAgo } },
{ expiresAt: { lt: thirtyDaysAgo } },
],
},
});
if (sessionsResult.count > 0) {
this.logger.log(`Cleaned up ${sessionsResult.count} expired sessions`);
}
} catch (err) {
this.logger.warn(`Session cleanup failed: ${err.message}`);
}
} catch (err) {
this.logger.error(`Notification cleanup failed: ${err.message}`);
}
return { deleted };
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PayrollService } from '../modules/payroll/payroll.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
export interface PayrollCalculatorResult {
contractorsProcessed: number;
totalNetPiasters: number;
}
@Injectable()
export class PayrollCalculatorProcessor {
private readonly logger = new Logger(PayrollCalculatorProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly payrollService: PayrollService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<PayrollCalculatorResult> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
// Check if payroll already exists and is past the CALCULATED stage
const existing = await this.prisma.payroll.findUnique({
where: { month_year: { month, year } },
});
if (existing && !['PENDING_CALCULATION', 'CALCULATED', 'REJECTED'].includes(existing.status)) {
this.logger.log(
`Payroll for ${month}/${year} is already in status "${existing.status}". Skipping auto-calculation.`,
);
return { contractorsProcessed: 0, totalNetPiasters: 0 };
}
// Create a system user context for the auto-calculation
const systemUser = {
id: 'SYSTEM',
email: 'system@thegrind.local',
role: 'SUPER_ADMIN',
sessionId: 'system-payroll-job',
};
try {
const result = await this.payrollService.calculate(month, year, systemUser as any);
const contractorsProcessed = result.contractorCount || 0;
const totalNetPiasters = result.totalNetPiasters || 0;
// Notify all admins that payroll has been auto-calculated
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: 'PAYROLL',
title: `Payroll Auto-Calculated: ${month}/${year}`,
message: `Monthly payroll for ${month}/${year} has been auto-calculated for ${contractorsProcessed} contractors. Total net: ${totalNetPiasters} piasters. Please review and submit for approval.`,
actionUrl: '/admin/payroll',
entityType: 'payroll',
entityId: result.id,
});
} catch {
// Non-critical
}
}
this.logger.log(
`Payroll for ${month}/${year} auto-calculated: ${contractorsProcessed} contractors, ${totalNetPiasters} piasters net`,
);
return { contractorsProcessed, totalNetPiasters };
} catch (err) {
this.logger.error(`Payroll auto-calculation failed for ${month}/${year}: ${err.message}`);
// Notify Super Admin of failure
const superAdmins = await this.prisma.user.findMany({
where: { role: 'SUPER_ADMIN', status: 'ACTIVE', deletedAt: null },
select: { id: true },
});
for (const sa of superAdmins) {
try {
await this.notificationsService.create({
userId: sa.id,
type: 'IMPORTANT',
category: 'SYSTEM',
title: 'Payroll Auto-Calculation Failed',
message: `The automatic payroll calculation for ${month}/${year} failed: ${err.message}. Please calculate manually.`,
actionUrl: '/admin/payroll',
});
} catch { /* non-critical */ }
}
return { contractorsProcessed: 0, totalNetPiasters: 0 };
}
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface RecurringCardResult {
cardsCreated: number;
}
/**
* Recurring Card Processor
*
* This processor creates card instances from recurring card definitions.
* The RecurringCardDefinition model is built in Phase 2B (Enhanced Kanban).
*
* Until Phase 2B is complete, this processor gracefully skips execution.
* Once the model exists, it will automatically start working.
*/
@Injectable()
export class RecurringCardProcessor {
private readonly logger = new Logger(RecurringCardProcessor.name);
constructor(private readonly prisma: PrismaService) {}
async process(): Promise<RecurringCardResult> {
let cardsCreated = 0;
try {
// Check if the RecurringCardDefinition model exists
const definitions = await (this.prisma as any).recurringCardDefinition?.findMany?.({
where: {
isActive: true,
nextCreationDate: { lte: new Date() },
},
include: {
board: { select: { id: true, key: true, nextCardNumber: true, deletedAt: true } },
},
});
if (!definitions || !Array.isArray(definitions)) {
this.logger.debug('RecurringCardDefinition model not available yet (Phase 2B). Skipping.');
return { cardsCreated: 0 };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const def of definitions) {
if (!def.board || def.board.deletedAt) {
this.logger.warn(`Recurring card definition ${def.id} references a deleted board. Skipping.`);
continue;
}
try {
// Find the Backlog column for this board
const backlogColumn = await this.prisma.column.findFirst({
where: { boardId: def.boardId, type: 'BACKLOG' },
});
if (!backlogColumn) {
this.logger.warn(`No Backlog column found for board ${def.boardId}. Skipping recurring card.`);
continue;
}
// Generate card number
const board = def.board;
const cardNumber = `${board.key}-${board.nextCardNumber}`;
await this.prisma.board.update({
where: { id: board.id },
data: { nextCardNumber: board.nextCardNumber + 1 },
});
// Calculate position
const maxPos = await this.prisma.card.aggregate({
where: { columnId: backlogColumn.id, deletedAt: null },
_max: { position: true },
});
const position = (maxPos._max?.position || 0) + 1;
// Create the card
const dateStr = today.toISOString().split('T')[0];
const title = def.titleTemplate
? def.titleTemplate.replace('[DATE]', dateStr).replace('[date]', dateStr)
: `${def.title}${dateStr}`;
await this.prisma.card.create({
data: {
title,
description: def.description || null,
cardNumber,
columnId: backlogColumn.id,
position,
priority: def.priority || 'NONE',
estimatedHours: def.estimatedHours || null,
bountyPiasters: 0,
createdById: def.createdById,
version: 1,
},
});
// Calculate next creation date based on recurrence pattern
const nextDate = this.calculateNextDate(def.recurrenceType, def.recurrenceConfig, today);
await (this.prisma as any).recurringCardDefinition.update({
where: { id: def.id },
data: {
lastCreatedAt: new Date(),
nextCreationDate: nextDate,
},
});
cardsCreated++;
this.logger.log(`Recurring card created: "${title}" on board ${board.key}`);
} catch (err) {
this.logger.error(`Failed to create recurring card from definition ${def.id}: ${err.message}`);
}
}
} catch (err) {
if (err.message?.includes('does not exist') || err.message?.includes('not found')) {
this.logger.debug('RecurringCardDefinition table does not exist yet. Skipping.');
} else {
this.logger.error(`Recurring card processor error: ${err.message}`);
}
}
return { cardsCreated };
}
private calculateNextDate(
recurrenceType: string,
config: any,
fromDate: Date,
): Date {
const next = new Date(fromDate);
switch (recurrenceType) {
case 'DAILY':
next.setDate(next.getDate() + 1);
break;
case 'WEEKLY':
next.setDate(next.getDate() + 7);
break;
case 'BIWEEKLY':
next.setDate(next.getDate() + 14);
break;
case 'MONTHLY':
next.setMonth(next.getMonth() + 1);
break;
case 'CUSTOM':
const intervalDays = config?.intervalDays || 7;
next.setDate(next.getDate() + intervalDays);
break;
default:
next.setDate(next.getDate() + 7);
}
return next;
}
}
\ No newline at end of file
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { UnreportedDayProcessor } from './unreported-day.processor';
import { DeadlineScannerProcessor } from './deadline-scanner.processor';
import { PayrollCalculatorProcessor } from './payroll-calculator.processor';
import { AutoArchiveProcessor } from './auto-archive.processor';
import { ContractExpiryProcessor } from './contract-expiry.processor';
import { DeductionAutoApplyProcessor } from './deduction-auto-apply.processor';
import { RecurringCardProcessor } from './recurring-card.processor';
import { MeetingReminderProcessor } from './meeting-reminder.processor';
import { NotificationCleanupProcessor } from './notification-cleanup.processor';
import { WebhookDispatchProcessor } from './webhook-dispatch.processor';
@Injectable()
export class SchedulerService implements OnModuleInit {
private readonly logger = new Logger(SchedulerService.name);
constructor(
private readonly unreportedDay: UnreportedDayProcessor,
private readonly deadlineScanner: DeadlineScannerProcessor,
private readonly payrollCalculator: PayrollCalculatorProcessor,
private readonly autoArchive: AutoArchiveProcessor,
private readonly contractExpiry: ContractExpiryProcessor,
private readonly deductionAutoApply: DeductionAutoApplyProcessor,
private readonly recurringCard: RecurringCardProcessor,
private readonly meetingReminder: MeetingReminderProcessor,
private readonly notificationCleanup: NotificationCleanupProcessor,
private readonly webhookDispatch: WebhookDispatchProcessor,
) {}
onModuleInit(): void {
this.logger.log('🕐 Job Scheduler initialized. All cron jobs registered.');
this.logger.log(' ├─ Unreported Day Detection: Daily at 01:00 AM');
this.logger.log(' ├─ Deadline Scanner: Every hour at :05');
this.logger.log(' ├─ Deduction Auto-Apply: Every hour at :30');
this.logger.log(' ├─ Auto Archive Done Cards: Daily at 02:00 AM');
this.logger.log(' ├─ Contract Expiry Check: Daily at 03:00 AM');
this.logger.log(' ├─ Recurring Card Creation: Daily at 04:00 AM');
this.logger.log(' ├─ Meeting Reminders: Every 30 minutes');
this.logger.log(' ├─ Notification Cleanup: Daily at 05:00 AM');
this.logger.log(' ├─ Payroll Calculator: Monthly on 25th at 06:00 AM');
this.logger.log(' └─ Webhook Retry: Every 15 minutes');
}
// ─── Daily at 1:00 AM — Detect unreported working days ───────────
@Cron('0 1 * * *', { name: 'unreported-day-detection', timeZone: 'Africa/Cairo' })
async handleUnreportedDayDetection(): Promise<void> {
this.logger.log('⏰ [CRON] Unreported Day Detection — START');
const start = Date.now();
try {
const result = await this.unreportedDay.process();
this.logger.log(
`✅ [CRON] Unreported Day Detection — DONE in ${Date.now() - start}ms. ` +
`${result.detected} unreported days found, ${result.deductionsCreated} deductions created.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Unreported Day Detection — FAILED: ${err.message}`, err.stack);
}
}
// ─── Every hour at :05 — Scan for overdue card deadlines ─────────
@Cron('5 * * * *', { name: 'deadline-scanner', timeZone: 'Africa/Cairo' })
async handleDeadlineScanner(): Promise<void> {
this.logger.log('⏰ [CRON] Deadline Scanner — START');
const start = Date.now();
try {
const result = await this.deadlineScanner.process();
this.logger.log(
`✅ [CRON] Deadline Scanner — DONE in ${Date.now() - start}ms. ` +
`${result.overdueCards} overdue cards found, ${result.deductionsCreated} new deductions, ${result.deductionsEscalated} escalated.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Deadline Scanner — FAILED: ${err.message}`, err.stack);
}
}
// ─── Every hour at :30 — Auto-apply expired deduction responses ──
@Cron('30 * * * *', { name: 'deduction-auto-apply', timeZone: 'Africa/Cairo' })
async handleDeductionAutoApply(): Promise<void> {
this.logger.log('⏰ [CRON] Deduction Auto-Apply — START');
const start = Date.now();
try {
const result = await this.deductionAutoApply.process();
this.logger.log(
`✅ [CRON] Deduction Auto-Apply — DONE in ${Date.now() - start}ms. ` +
`${result.applied} deductions auto-applied.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Deduction Auto-Apply — FAILED: ${err.message}`, err.stack);
}
}
// ─── Daily at 2:00 AM — Archive old Done cards ───────────────────
@Cron('0 2 * * *', { name: 'auto-archive', timeZone: 'Africa/Cairo' })
async handleAutoArchive(): Promise<void> {
this.logger.log('⏰ [CRON] Auto Archive — START');
const start = Date.now();
try {
const result = await this.autoArchive.process();
this.logger.log(
`✅ [CRON] Auto Archive — DONE in ${Date.now() - start}ms. ` +
`${result.archived} cards archived across ${result.boardsProcessed} boards.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Auto Archive — FAILED: ${err.message}`, err.stack);
}
}
// ─── Daily at 3:00 AM — Check for expiring contracts ─────────────
@Cron('0 3 * * *', { name: 'contract-expiry', timeZone: 'Africa/Cairo' })
async handleContractExpiry(): Promise<void> {
this.logger.log('⏰ [CRON] Contract Expiry Check — START');
const start = Date.now();
try {
const result = await this.contractExpiry.process();
this.logger.log(
`✅ [CRON] Contract Expiry Check — DONE in ${Date.now() - start}ms. ` +
`${result.notificationsSent} expiry notifications sent.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Contract Expiry Check — FAILED: ${err.message}`, err.stack);
}
}
// ─── Daily at 4:00 AM — Create recurring card instances ──────────
@Cron('0 4 * * *', { name: 'recurring-card', timeZone: 'Africa/Cairo' })
async handleRecurringCard(): Promise<void> {
this.logger.log('⏰ [CRON] Recurring Card Creation — START');
const start = Date.now();
try {
const result = await this.recurringCard.process();
this.logger.log(
`✅ [CRON] Recurring Card Creation — DONE in ${Date.now() - start}ms. ` +
`${result.cardsCreated} cards created.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Recurring Card Creation — FAILED: ${err.message}`, err.stack);
}
}
// ─── Every 30 minutes — Send meeting reminders ───────────────────
@Cron('*/30 * * * *', { name: 'meeting-reminder', timeZone: 'Africa/Cairo' })
async handleMeetingReminder(): Promise<void> {
this.logger.debug('⏰ [CRON] Meeting Reminder — START');
const start = Date.now();
try {
const result = await this.meetingReminder.process();
if (result.remindersSent > 0) {
this.logger.log(
`✅ [CRON] Meeting Reminder — DONE in ${Date.now() - start}ms. ` +
`${result.remindersSent} reminders sent.`,
);
}
} catch (err) {
this.logger.error(`❌ [CRON] Meeting Reminder — FAILED: ${err.message}`, err.stack);
}
}
// ─── Daily at 5:00 AM — Cleanup old notifications ────────────────
@Cron('0 5 * * *', { name: 'notification-cleanup', timeZone: 'Africa/Cairo' })
async handleNotificationCleanup(): Promise<void> {
this.logger.log('⏰ [CRON] Notification Cleanup — START');
const start = Date.now();
try {
const result = await this.notificationCleanup.process();
this.logger.log(
`✅ [CRON] Notification Cleanup — DONE in ${Date.now() - start}ms. ` +
`${result.deleted} old notifications removed.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Notification Cleanup — FAILED: ${err.message}`, err.stack);
}
}
// ─── Monthly on the 25th at 6:00 AM — Auto-calculate payroll ─────
@Cron('0 6 25 * *', { name: 'payroll-calculator', timeZone: 'Africa/Cairo' })
async handlePayrollCalculation(): Promise<void> {
this.logger.log('⏰ [CRON] Payroll Auto-Calculation — START');
const start = Date.now();
try {
const result = await this.payrollCalculator.process();
this.logger.log(
`✅ [CRON] Payroll Auto-Calculation — DONE in ${Date.now() - start}ms. ` +
`${result.contractorsProcessed} contractors, ${result.totalNetPiasters} piasters total net.`,
);
} catch (err) {
this.logger.error(`❌ [CRON] Payroll Auto-Calculation — FAILED: ${err.message}`, err.stack);
}
}
// ─── Every 15 minutes — Retry failed webhooks ────────────────────
@Cron('*/15 * * * *', { name: 'webhook-retry', timeZone: 'Africa/Cairo' })
async handleWebhookRetry(): Promise<void> {
this.logger.debug('⏰ [CRON] Webhook Retry — START');
const start = Date.now();
try {
const result = await this.webhookDispatch.processRetries();
if (result.retried > 0) {
this.logger.log(
`✅ [CRON] Webhook Retry — DONE in ${Date.now() - start}ms. ` +
`${result.retried} webhooks retried, ${result.succeeded} succeeded, ${result.failed} failed.`,
);
}
} catch (err) {
this.logger.error(`❌ [CRON] Webhook Retry — FAILED: ${err.message}`, err.stack);
}
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../modules/notifications/notifications.service';
import { getScheduledDaysOfWeek } from '../common/utils/date.util';
export interface UnreportedDayResult {
detected: number;
deductionsCreated: number;
}
@Injectable()
export class UnreportedDayProcessor {
private readonly logger = new Logger(UnreportedDayProcessor.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationsService: NotificationsService,
) {}
async process(): Promise<UnreportedDayResult> {
let detected = 0;
let deductionsCreated = 0;
// This processor checks for unreported working days.
// It runs daily at 1 AM, checking YESTERDAY (giving the full grace period).
//
// The DailyReport model is built in Phase 2D. Until then, this processor
// checks if the model exists and gracefully skips if not.
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const dayOfWeek = yesterday.getDay(); // 0=Sunday, 6=Saturday
// Get all active contractors
const contractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP'] },
deletedAt: null,
},
select: {
id: true,
firstName: true,
lastName: true,
weeklySchedule: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
},
});
// Check if yesterday was a holiday
const isHoliday = await this.isHoliday(yesterday);
if (isHoliday) {
this.logger.log('Yesterday was a holiday. No unreported day detection needed.');
return { detected: 0, deductionsCreated: 0 };
}
for (const contractor of contractors) {
try {
const schedule = (contractor.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
// Check if yesterday was a scheduled working day for this contractor
if (!scheduledDays.includes(dayOfWeek)) {
continue; // Not a working day for this contractor
}
// Check if unavailability was logged for yesterday
const unavailability = await this.prisma.unavailability?.findFirst?.({
where: {
userId: contractor.id,
startDate: { lte: yesterday },
endDate: { gte: yesterday },
},
}).catch(() => null);
if (unavailability) {
continue; // Unavailability logged — not unreported
}
// Check if a daily report exists for yesterday
// DailyReport model is built in Phase 2D — try to access it
let reportExists = false;
try {
const report = await (this.prisma as any).dailyReport?.findFirst?.({
where: {
userId: contractor.id,
reportDate: {
gte: yesterday,
lt: new Date(yesterday.getTime() + 24 * 60 * 60 * 1000),
},
status: { not: 'DRAFT' },
},
});
reportExists = !!report;
} catch {
// DailyReport model doesn't exist yet — skip report checking
// Once Phase 2D is built, this will work automatically
this.logger.debug('DailyReport model not available yet. Skipping report check.');
continue;
}
if (reportExists) {
continue; // Report exists — not unreported
}
// ─── UNREPORTED DAY DETECTED ────────────────────────────
detected++;
// Calculate deduction amount: Full Daily Rate for the unreported day
const salary = contractor.actualSalaryPiasters || contractor.baseSalaryPiasters || 0;
const month = yesterday.getMonth() + 1;
const year = yesterday.getFullYear();
// Get expected working days for the month to calculate daily rate
const daysInMonth = new Date(year, month, 0).getDate();
let workingDaysInMonth = 0;
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month - 1, d);
if (scheduledDays.includes(date.getDay())) {
workingDaysInMonth++;
}
}
const dailyRate = workingDaysInMonth > 0 ? Math.round(salary / workingDaysInMonth) : 0;
// Create Category B2 deduction
try {
const deduction = await this.prisma.deduction.create({
data: {
userId: contractor.id,
category: 'B',
subCategory: 'B2',
violationDate: yesterday,
description: (
`Unreported day: ${yesterday.toISOString().split('T')[0]}. ` +
`No daily report was submitted and no unavailability was logged ` +
`for this scheduled working day. This was detected automatically ` +
`by the system after the grace period expired.`
),
amountPiasters: dailyRate,
originalAmountPiasters: dailyRate,
calculationBasis: `Category B2 — Full daily rate: ${dailyRate} piasters (${salary} salary / ${workingDaysInMonth} working days)`,
status: 'PENDING_ACKNOWLEDGMENT',
initiatedById: null,
initiatedByRole: 'SYSTEM',
payrollMonth: new Date().getMonth() + 1,
payrollYear: new Date().getFullYear(),
},
});
deductionsCreated++;
// Notify the contractor
await this.notificationsService.create({
userId: contractor.id,
type: 'BLOCKING',
category: 'DEDUCTION',
title: `Unreported Day: ${yesterday.toISOString().split('T')[0]}`,
message: `You have an unreported working day. No report was submitted and no unavailability was logged for ${yesterday.toISOString().split('T')[0]}. A deduction of ${dailyRate} piasters has been initiated.`,
actionUrl: '/salary',
isBlocking: true,
entityType: 'deduction',
entityId: deduction.id,
});
this.logger.log(
`Unreported day detected: ${contractor.firstName} ${contractor.lastName} on ${yesterday.toISOString().split('T')[0]} — deduction of ${dailyRate} piasters`,
);
} catch (err) {
this.logger.error(
`Failed to create B2 deduction for ${contractor.id}: ${err.message}`,
);
}
// Check for consecutive unreported days (3+ triggers escalation)
await this.checkConsecutiveUnreported(contractor);
} catch (err) {
this.logger.error(
`Error checking unreported day for contractor ${contractor.id}: ${err.message}`,
);
}
}
return { detected, deductionsCreated };
}
private async isHoliday(date: Date): Promise<boolean> {
try {
const holiday = await this.prisma.holiday.findFirst({
where: {
startDate: { lte: date },
endDate: { gte: date },
},
});
return !!holiday;
} catch {
return false; // Holiday table might not exist yet
}
}
private async checkConsecutiveUnreported(contractor: any): Promise<void> {
try {
// Count B2 deductions in the last 7 days
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const recentB2 = await this.prisma.deduction.count({
where: {
userId: contractor.id,
subCategory: 'B2',
violationDate: { gte: sevenDaysAgo },
},
});
if (recentB2 >= 3) {
this.logger.warn(
`⚠️ ESCALATION: ${contractor.firstName} ${contractor.lastName} has ${recentB2} unreported days in the last 7 days`,
);
// Notify admins and assigned PL
const notifyUsers = await this.prisma.user.findMany({
where: {
OR: [
{ role: { in: ['SUPER_ADMIN', 'ADMIN'] }, status: 'ACTIVE', deletedAt: null },
{ id: contractor.assignedProjectLeaderId || 'none' },
],
},
select: { id: true },
});
for (const user of notifyUsers) {
try {
await this.notificationsService.create({
userId: user.id,
type: 'IMPORTANT',
category: 'DEDUCTION',
title: `Escalation: ${contractor.firstName} ${contractor.lastName}${recentB2} Unreported Days`,
message: `Contractor has ${recentB2} unreported days in the past 7 days. This may indicate a disappearance (Category D3). Please investigate.`,
actionUrl: `/admin/contractors/${contractor.id}`,
entityType: 'user',
entityId: contractor.id,
});
} catch { /* non-critical */ }
}
}
} catch (err) {
this.logger.warn(`Failed to check consecutive unreported: ${err.message}`);
}
}
}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export interface WebhookRetryResult {
retried: number;
succeeded: number;
failed: number;
}
/**
* Webhook Dispatch Processor
*
* Handles retry logic for failed webhook deliveries.
* The Webhook and WebhookDelivery models are built in Phase 3B (API & Integration).
*
* Until Phase 3B is complete, this processor gracefully skips execution.
*
* Retry logic: 3 attempts with exponential backoff
* - Attempt 1: Immediate
* - Attempt 2: After 1 minute
* - Attempt 3: After 5 minutes
* - After 3 failures: Marked as permanently failed
*/
@Injectable()
export class WebhookDispatchProcessor {
private readonly logger = new Logger(WebhookDispatchProcessor.name);
constructor(private readonly prisma: PrismaService) {}
async processRetries(): Promise<WebhookRetryResult> {
let retried = 0;
let succeeded = 0;
let failed = 0;
try {
// Check if Webhook models exist
const webhookModel = (this.prisma as any).webhook;
const deliveryModel = (this.prisma as any).webhookDelivery;
if (!webhookModel || !deliveryModel ||
typeof webhookModel.findMany !== 'function' ||
typeof deliveryModel.findMany !== 'function') {
// Models don't exist yet (Phase 3B)
return { retried: 0, succeeded: 0, failed: 0 };
}
// Find failed deliveries eligible for retry
const failedDeliveries = await deliveryModel.findMany({
where: {
status: 'FAILED',
attempts: { lt: 3 },
nextRetryAt: { lte: new Date() },
},
include: {
webhook: {
select: { url: true, secret: true, isActive: true, events: true },
},
},
take: 50, // Process max 50 retries per run
});
for (const delivery of failedDeliveries) {
if (!delivery.webhook.isActive) {
// Webhook was deactivated — skip
await deliveryModel.update({
where: { id: delivery.id },
data: { status: 'CANCELLED' },
});
continue;
}
retried++;
try {
// Attempt the HTTP request
const response = await fetch(delivery.webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': this.computeSignature(
delivery.payload,
delivery.webhook.secret,
),
'X-Webhook-Event': delivery.event,
'X-Webhook-Delivery': delivery.id,
'X-Webhook-Attempt': String(delivery.attempts + 1),
},
body: JSON.stringify(delivery.payload),
signal: AbortSignal.timeout(10000), // 10 second timeout
});
if (response.ok) {
await deliveryModel.update({
where: { id: delivery.id },
data: {
status: 'DELIVERED',
responseCode: response.status,
deliveredAt: new Date(),
attempts: delivery.attempts + 1,
},
});
succeeded++;
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (err) {
const newAttempts = delivery.attempts + 1;
const isPermanentlyFailed = newAttempts >= 3;
// Calculate next retry with exponential backoff
const backoffMinutes = [0, 1, 5]; // Attempt 1: 0min, 2: 1min, 3: 5min
const nextRetryAt = isPermanentlyFailed
? null
: new Date(Date.now() + (backoffMinutes[newAttempts] || 5) * 60 * 1000);
await deliveryModel.update({
where: { id: delivery.id },
data: {
status: isPermanentlyFailed ? 'PERMANENTLY_FAILED' : 'FAILED',
lastError: err.message,
attempts: newAttempts,
nextRetryAt,
},
});
if (isPermanentlyFailed) {
failed++;
this.logger.warn(
`Webhook delivery ${delivery.id} permanently failed after 3 attempts: ${err.message}`,
);
}
}
}
} catch (err) {
if (err.message?.includes('does not exist') || err.code === 'P2021') {
// Models don't exist yet — Phase 3B
} else {
this.logger.error(`Webhook retry processor error: ${err.message}`);
}
}
return { retried, succeeded, failed };
}
/**
* Dispatch a webhook event immediately.
* Called by other services when events occur.
*/
async dispatch(event: string, payload: any): Promise<void> {
try {
const webhookModel = (this.prisma as any).webhook;
const deliveryModel = (this.prisma as any).webhookDelivery;
if (!webhookModel || !deliveryModel) return;
// Find all active webhooks subscribed to this event
const webhooks = await webhookModel.findMany({
where: {
isActive: true,
events: { has: event },
},
});
for (const webhook of webhooks) {
try {
// Create delivery record
const delivery = await deliveryModel.create({
data: {
webhookId: webhook.id,
event,
payload,
status: 'PENDING',
attempts: 0,
},
});
// Attempt immediate delivery
const response = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': this.computeSignature(payload, webhook.secret),
'X-Webhook-Event': event,
'X-Webhook-Delivery': delivery.id,
'X-Webhook-Attempt': '1',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
});
if (response.ok) {
await deliveryModel.update({
where: { id: delivery.id },
data: {
status: 'DELIVERED',
responseCode: response.status,
deliveredAt: new Date(),
attempts: 1,
},
});
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
// First attempt failed — mark for retry
this.logger.warn(`Webhook dispatch to ${webhook.url} failed: ${err.message}. Will retry.`);
}
}
} catch (err) {
// Models don't exist yet or other error — silently skip
if (!err.message?.includes('does not exist')) {
this.logger.error(`Webhook dispatch error: ${err.message}`);
}
}
}
private computeSignature(payload: any, secret: string): string {
if (!secret) return '';
try {
const crypto = require('crypto');
return crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
} catch {
return '';
}
}
}
\ 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