- 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
392 lines
11 KiB
TypeScript
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);
|