wulf-pulse/lib/services/background-processor.ts
root 6eee14f8af Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser
- Implement RMM, Auvik, and Addigy organization mappings
- Add chunked ticket sync with progress tracking
- Implement entity sync service with rate limiting
- Add analytics engine and performance optimizer
- Create data browser for all PSA entities
- Add navigation components and UI improvements
- Implement background processing and sync services
- Add comprehensive documentation and migration scripts
- Update configuration items with multi-system support
- Enhance contact management and purchase history
- Add issue type assignment and LLM analyzer
- Improve error handling and logging utilities
2025-11-19 14:18:16 -05:00

392 lines
11 KiB
TypeScript

/**
* Background Processor Service
* Handles batch processing of historical time entries data for analytics
*/
import { TimeEntry } from '@/lib/types/database';
import { analyticsEngine } from './analytics-engine';
import { llmAnalyzer } from './llm-analyzer';
import { postgresClient } from './postgres-client';
export interface BatchAnalysisJob {
id: string;
type: 'scoring' | 'llm_analysis' | 'full_analysis';
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number; // 0 to 100
totalRecords: number;
processedRecords: number;
startTime: Date;
endTime?: Date;
errorMessage?: string;
filters?: {
startDate?: Date;
endDate?: Date;
resourceIds?: number[];
projectIds?: number[];
};
}
export interface ProcessingResult {
jobId: string;
success: boolean;
recordsProcessed: number;
errors: string[];
processingTime: number; // milliseconds
}
export class BackgroundProcessor {
private jobs: Map<string, BatchAnalysisJob> = new Map();
private isProcessing: boolean = false;
private batchSize: number = 100; // Process 100 records at a time
private maxConcurrentJobs: number = 3;
private processingQueue: string[] = [];
/**
* Start a batch analysis job
*/
async startBatchAnalysis(
type: 'scoring' | 'llm_analysis' | 'full_analysis',
filters?: {
startDate?: Date;
endDate?: Date;
resourceIds?: number[];
projectIds?: number[];
}
): Promise<string> {
const jobId = this.generateJobId();
const job: BatchAnalysisJob = {
id: jobId,
type,
status: 'pending',
progress: 0,
totalRecords: 0,
processedRecords: 0,
startTime: new Date(),
filters,
};
this.jobs.set(jobId, job);
this.processingQueue.push(jobId);
// Start processing if not already running
this.processQueue();
return jobId;
}
/**
* Get job status
*/
getJobStatus(jobId: string): BatchAnalysisJob | null {
return this.jobs.get(jobId) || null;
}
/**
* Get all jobs
*/
getAllJobs(): BatchAnalysisJob[] {
return Array.from(this.jobs.values());
}
/**
* Cancel a job
*/
cancelJob(jobId: string): boolean {
const job = this.jobs.get(jobId);
if (!job || job.status === 'completed' || job.status === 'running') {
return false;
}
job.status = 'failed';
job.errorMessage = 'Job cancelled by user';
job.endTime = new Date();
// Remove from queue
const queueIndex = this.processingQueue.indexOf(jobId);
if (queueIndex > -1) {
this.processingQueue.splice(queueIndex, 1);
}
return true;
}
/**
* Process the job queue
*/
private async processQueue(): Promise<void> {
if (this.isProcessing || this.processingQueue.length === 0) {
return;
}
this.isProcessing = true;
while (this.processingQueue.length > 0) {
const runningJobs = Array.from(this.jobs.values())
.filter(job => job.status === 'running').length;
if (runningJobs >= this.maxConcurrentJobs) {
break; // Wait for current jobs to finish
}
const jobId = this.processingQueue.shift();
if (!jobId) continue;
const job = this.jobs.get(jobId);
if (!job || job.status !== 'pending') continue;
// Process job in background
this.processJob(jobId).catch(error => {
console.error(`Background job ${jobId} failed:`, error);
});
}
this.isProcessing = false;
}
/**
* Process a single job
*/
private async processJob(jobId: string): Promise<void> {
const job = this.jobs.get(jobId);
if (!job) return;
try {
job.status = 'running';
// Get time entries to process
const timeEntries = await this.getTimeEntriesForJob(job);
job.totalRecords = timeEntries.length;
if (timeEntries.length === 0) {
job.status = 'completed';
job.progress = 100;
job.endTime = new Date();
return;
}
// Process based on job type
switch (job.type) {
case 'scoring':
await this.processScoringJob(job, timeEntries);
break;
case 'llm_analysis':
await this.processLLMAnalysisJob(job, timeEntries);
break;
case 'full_analysis':
await this.processFullAnalysisJob(job, timeEntries);
break;
}
job.status = 'completed';
job.progress = 100;
job.endTime = new Date();
console.log(`Background job ${jobId} completed successfully`);
} catch (error) {
job.status = 'failed';
job.errorMessage = error instanceof Error ? error.message : 'Unknown error';
job.endTime = new Date();
console.error(`Background job ${jobId} failed:`, error);
}
// Continue processing queue
setTimeout(() => this.processQueue(), 100);
}
/**
* Process scoring job
*/
private async processScoringJob(job: BatchAnalysisJob, timeEntries: TimeEntry[]): Promise<void> {
const analyses = [];
for (let i = 0; i < timeEntries.length; i += this.batchSize) {
const batch = timeEntries.slice(i, i + this.batchSize);
// Analyze batch
for (const entry of batch) {
const analysis = analyticsEngine.analyzeTimeEntry(entry);
analyses.push(analysis);
// Store analysis in database (would need to create analysis table)
await this.storeTimeEntryAnalysis(entry.id, analysis);
job.processedRecords++;
job.progress = Math.round((job.processedRecords / job.totalRecords) * 100);
}
// Small delay to prevent overwhelming the system
await new Promise(resolve => setTimeout(resolve, 10));
}
console.log(`Scoring job ${job.id}: Analyzed ${analyses.length} time entries`);
}
/**
* Process LLM analysis job
*/
private async processLLMAnalysisJob(job: BatchAnalysisJob, timeEntries: TimeEntry[]): Promise<void> {
// Process in larger batches for LLM to be more cost-effective
const llmBatchSize = 500;
for (let i = 0; i < timeEntries.length; i += llmBatchSize) {
const batch = timeEntries.slice(i, i + llmBatchSize);
try {
const insights = await llmAnalyzer.generateInsights(batch);
// Store insights in database
await this.storeLLMInsights(job.id, batch, insights);
job.processedRecords += batch.length;
job.progress = Math.round((job.processedRecords / job.totalRecords) * 100);
// Longer delay for LLM processing
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error(`LLM analysis failed for batch ${i}-${i + batch.length}:`, error);
// Continue with next batch
}
}
console.log(`LLM analysis job ${job.id}: Processed ${timeEntries.length} time entries`);
}
/**
* Process full analysis job
*/
private async processFullAnalysisJob(job: BatchAnalysisJob, timeEntries: TimeEntry[]): Promise<void> {
// First, run scoring
await this.processScoringJob(job, timeEntries);
// Then, run LLM analysis on aggregated data
const insights = await llmAnalyzer.generateInsights(timeEntries);
const aggregateAnalysis = analyticsEngine.analyzeTimeEntries(timeEntries);
// Store comprehensive results
await this.storeFullAnalysisResults(job.id, aggregateAnalysis, insights);
console.log(`Full analysis job ${job.id}: Completed comprehensive analysis`);
}
/**
* Get time entries for job based on filters
*/
private async getTimeEntriesForJob(job: BatchAnalysisJob): Promise<TimeEntry[]> {
let query = 'SELECT * FROM time_entries WHERE is_deleted = false';
const params: any[] = [];
let paramIndex = 1;
if (job.filters?.startDate) {
query += ` AND entry_date >= $${paramIndex}`;
params.push(job.filters.startDate.toISOString());
paramIndex++;
}
if (job.filters?.endDate) {
query += ` AND entry_date <= $${paramIndex}`;
params.push(job.filters.endDate.toISOString());
paramIndex++;
}
if (job.filters?.resourceIds && job.filters.resourceIds.length > 0) {
query += ` AND resource_id = ANY($${paramIndex})`;
params.push(job.filters.resourceIds);
paramIndex++;
}
if (job.filters?.projectIds && job.filters.projectIds.length > 0) {
query += ` AND project_id = ANY($${paramIndex})`;
params.push(job.filters.projectIds);
paramIndex++;
}
query += ' ORDER BY entry_date DESC';
const result = await postgresClient.query(query, params);
return result.rows;
}
/**
* Store time entry analysis in database
*/
private async storeTimeEntryAnalysis(timeEntryId: number, analysis: any): Promise<void> {
// This would create/update a time_entry_analyses table
// For now, just log the analysis
console.log(`Storing analysis for time entry ${timeEntryId}:`, analysis.overallScore);
}
/**
* Store LLM insights in database
*/
private async storeLLMInsights(jobId: string, timeEntries: TimeEntry[], insights: any[]): Promise<void> {
// This would create/update an llm_insights table
console.log(`Storing ${insights.length} LLM insights for job ${jobId}`);
}
/**
* Store full analysis results
*/
private async storeFullAnalysisResults(jobId: string, analysis: any, insights: any[]): Promise<void> {
// This would create/update a comprehensive analysis results table
console.log(`Storing full analysis results for job ${jobId}`);
}
/**
* Generate unique job ID
*/
private generateJobId(): string {
return `job_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Clean up old completed jobs
*/
cleanupOldJobs(maxAge: number = 24 * 60 * 60 * 1000): void { // 24 hours
const cutoff = Date.now() - maxAge;
for (const [jobId, job] of this.jobs.entries()) {
if (
(job.status === 'completed' || job.status === 'failed') &&
job.endTime &&
job.endTime.getTime() < cutoff
) {
this.jobs.delete(jobId);
}
}
}
/**
* Get processing statistics
*/
getStats(): {
totalJobs: number;
pendingJobs: number;
runningJobs: number;
completedJobs: number;
failedJobs: number;
queueLength: number;
} {
const jobs = Array.from(this.jobs.values());
return {
totalJobs: jobs.length,
pendingJobs: jobs.filter(j => j.status === 'pending').length,
runningJobs: jobs.filter(j => j.status === 'running').length,
completedJobs: jobs.filter(j => j.status === 'completed').length,
failedJobs: jobs.filter(j => j.status === 'failed').length,
queueLength: this.processingQueue.length,
};
}
}
// Create singleton instance
export const backgroundProcessor = new BackgroundProcessor();
// Schedule cleanup every hour
setInterval(() => {
backgroundProcessor.cleanupOldJobs();
}, 60 * 60 * 1000);