wulf-pulse/lib/services/analytics-integration.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

477 lines
15 KiB
TypeScript

/**
* Analytics Integration Service
* Integrates Time Entries analytics with existing entities for enriched analysis
*/
import { TimeEntry } from '@/lib/types/database';
import { analyticsEngine } from './analytics-engine';
import { llmAnalyzer } from './llm-analyzer';
import { postgresClient } from './postgres-client';
import {
TimelineEvent,
AnalyticsInsight,
AggregateAnalysis,
LLMAnalysisRequest
} from '@/lib/types/analytics';
export interface EnrichedTimeEntry extends TimeEntry {
resource_name?: string;
ticket_title?: string;
ticket_number?: string;
task_title?: string;
project_name?: string;
company_name?: string;
analysis?: {
activityScore: number;
contentScore: number;
timelinessScore: number;
overallScore: number;
};
}
export interface EnrichmentOptions {
includeResourceInfo?: boolean;
includeTicketInfo?: boolean;
includeTaskInfo?: boolean;
includeProjectInfo?: boolean;
includeCompanyInfo?: boolean;
includeAnalysis?: boolean;
includeRelatedEntities?: boolean;
}
export class AnalyticsIntegrationService {
/**
* Enrich time entries with related entity data and analysis
*/
async enrichTimeEntries(
timeEntries: TimeEntry[],
options: EnrichmentOptions = {}
): Promise<EnrichedTimeEntry[]> {
const enriched: EnrichedTimeEntry[] = [];
// Extract IDs for batch queries
const resourceIds = [...new Set(timeEntries.map(te => te.resource_id).filter((id): id is number => id != null))];
const ticketIds = [...new Set(timeEntries.map(te => te.ticket_id).filter((id): id is number => id != null))];
const taskIds = [...new Set(timeEntries.map(te => te.task_id).filter((id): id is number => id != null))];
const projectIds = [...new Set(timeEntries.map(te => te.project_id).filter((id): id is number => id != null))];
const companyIds = [...new Set(timeEntries.map(te => te.company_id).filter((id): id is number => id != null))];
// Batch fetch related entities
const [resources, tickets, tasks, projects, companies] = await Promise.all([
options.includeResourceInfo && resourceIds.length > 0
? this.getResources(resourceIds)
: Promise.resolve([]),
options.includeTicketInfo && ticketIds.length > 0
? this.getTickets(ticketIds)
: Promise.resolve([]),
options.includeTaskInfo && taskIds.length > 0
? this.getTasks(taskIds)
: Promise.resolve([]),
options.includeProjectInfo && projectIds.length > 0
? this.getProjects(projectIds)
: Promise.resolve([]),
options.includeCompanyInfo && companyIds.length > 0
? this.getCompanies(companyIds)
: Promise.resolve([]),
]);
// Create lookup maps
const resourceMap = new Map(resources.map(r => [r.id, r]));
const ticketMap = new Map(tickets.map(t => [t.id, t]));
const taskMap = new Map(tasks.map(t => [t.id, t]));
const projectMap = new Map(projects.map(p => [p.id, p]));
const companyMap = new Map(companies.map(c => [c.id, c]));
// Enrich each time entry
for (const timeEntry of timeEntries) {
const enrichedEntry: EnrichedTimeEntry = { ...timeEntry };
// Add related entity information
if (options.includeResourceInfo) {
const resource = resourceMap.get(timeEntry.resource_id);
if (resource) {
enrichedEntry.resource_name = `${resource.first_name} ${resource.last_name}`;
}
}
if (options.includeTicketInfo) {
const ticket = ticketMap.get(timeEntry.ticket_id);
if (ticket) {
enrichedEntry.ticket_title = ticket.title;
enrichedEntry.ticket_number = ticket.ticket_number;
}
}
if (options.includeTaskInfo) {
const task = taskMap.get(timeEntry.task_id);
if (task) {
enrichedEntry.task_title = task.title;
}
}
if (options.includeProjectInfo) {
const project = projectMap.get(timeEntry.project_id);
if (project) {
enrichedEntry.project_name = project.project_name;
}
}
if (options.includeCompanyInfo) {
const company = companyMap.get(timeEntry.company_id);
if (company) {
enrichedEntry.company_name = company.company_name;
}
}
// Add analysis
if (options.includeAnalysis) {
const analysis = analyticsEngine.analyzeTimeEntry(timeEntry);
enrichedEntry.analysis = {
activityScore: analysis.activityScore.score,
contentScore: analysis.contentScore.score,
timelinessScore: analysis.timelinessScore.score,
overallScore: analysis.overallScore,
};
}
enriched.push(enrichedEntry);
}
return enriched;
}
/**
* Generate timeline events from enriched time entries
*/
async generateTimelineEvents(
timeEntries: TimeEntry[],
options: EnrichmentOptions = {
includeResourceInfo: true,
includeTicketInfo: true,
includeTaskInfo: true,
includeProjectInfo: true,
includeCompanyInfo: true,
}
): Promise<TimelineEvent[]> {
const enrichedEntries = await this.enrichTimeEntries(timeEntries, options);
const events: TimelineEvent[] = enrichedEntries.map(entry => ({
id: `te-${entry.id}`,
type: 'time_entry',
timestamp: new Date(entry.entry_date),
title: entry.title || 'Time Entry',
description: this.generateEventDescription(entry),
duration: entry.hours_worked,
metadata: {
timeEntryId: entry.id,
resourceId: entry.resource_id,
resourceName: entry.resource_name,
ticketId: entry.ticket_id,
ticketTitle: entry.ticket_title,
ticketNumber: entry.ticket_number,
taskId: entry.task_id,
taskTitle: entry.task_title,
projectId: entry.project_id,
projectName: entry.project_name,
companyId: entry.company_id,
companyName: entry.company_name,
billable: entry.billable,
approved: entry.approved,
score: entry.analysis?.overallScore,
},
score: entry.analysis?.overallScore,
isHumanActivity: true,
importance: this.determineImportance(entry),
}));
// Add key moments if requested
if (options.includeRelatedEntities) {
const keyMomentEvents = await this.generateKeyMomentEvents(timeEntries);
events.push(...keyMomentEvents);
}
// Sort by timestamp
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}
/**
* Generate comprehensive analysis with entity context
*/
async generateComprehensiveAnalysis(
timeEntries: TimeEntry[],
options: EnrichmentOptions = {
includeResourceInfo: true,
includeTicketInfo: true,
includeProjectInfo: true,
includeCompanyInfo: true,
includeAnalysis: true,
}
): Promise<{
analysis: AggregateAnalysis;
enrichedEntries: EnrichedTimeEntry[];
entityInsights: AnalyticsInsight[];
}> {
// Enrich time entries
const enrichedEntries = await this.enrichTimeEntries(timeEntries, options);
// Generate basic analysis
const analysis = analyticsEngine.analyzeTimeEntries(timeEntries);
// Generate entity-specific insights
const entityInsights = await this.generateEntityInsights(enrichedEntries);
// Combine insights
analysis.insights = [...analysis.insights, ...entityInsights];
return {
analysis,
enrichedEntries,
entityInsights,
};
}
/**
* Generate LLM analysis with enriched context
*/
async generateLLMAnalysis(
timeEntries: TimeEntry[],
analysisType: 'productivity' | 'quality' | 'patterns' | 'anomalies' | 'comprehensive' = 'comprehensive',
options: EnrichmentOptions = {
includeResourceInfo: true,
includeTicketInfo: true,
includeProjectInfo: true,
includeCompanyInfo: true,
}
) {
const enrichedEntries = await this.enrichTimeEntries(timeEntries, options);
const request: LLMAnalysisRequest = {
timeEntries: enrichedEntries.map(entry => ({
id: entry.id,
notes: entry.notes || undefined,
title: entry.title || undefined,
hours_worked: entry.hours_worked,
entry_date: entry.entry_date.toISOString(),
resource_name: entry.resource_name,
ticket_title: entry.ticket_title,
})),
analysisType,
context: {
timeRange: `${new Date().toISOString().split('T')[0]}`,
resourceIds: [...new Set(enrichedEntries.map(e => e.resource_id).filter((id): id is number => id != null))],
projectIds: [...new Set(enrichedEntries.map(e => e.project_id).filter((id): id is number => id != null))],
},
};
return llmAnalyzer.analyzeTimeEntries(request);
}
/**
* Generate entity-specific insights
*/
private async generateEntityInsights(enrichedEntries: EnrichedTimeEntry[]): Promise<AnalyticsInsight[]> {
const insights: AnalyticsInsight[] = [];
// Resource-based insights
const resourceGroups = this.groupBy(enrichedEntries, 'resource_id');
for (const [resourceId, entries] of Object.entries(resourceGroups)) {
const resourceName = entries[0]?.resource_name || `Resource ${resourceId}`;
const avgScore = entries.reduce((sum, e) => sum + (e.analysis?.overallScore || 0), 0) / entries.length;
if (avgScore < 0.5) {
insights.push({
type: 'warning',
category: 'activity',
title: `Low Performance: ${resourceName}`,
description: `${resourceName} has an average score of ${(avgScore * 100).toFixed(1)}%`,
recommendation: 'Review time entry quality and provide training if needed',
severity: 'medium',
actionable: true,
});
}
}
// Project-based insights
const projectGroups = this.groupBy(enrichedEntries, 'project_id');
for (const [projectId, entries] of Object.entries(projectGroups)) {
const projectName = entries[0]?.project_name || `Project ${projectId}`;
const totalHours = entries.reduce((sum, e) => sum + e.hours_worked, 0);
if (totalHours > 100) {
insights.push({
type: 'info',
category: 'performance',
title: `High Activity: ${projectName}`,
description: `${Number(totalHours).toFixed(1)} hours logged for this project`,
recommendation: 'Monitor project progress and resource allocation',
severity: 'low',
actionable: true,
});
}
}
// Ticket-based insights
const ticketGroups = this.groupBy(enrichedEntries, 'ticket_id');
for (const [ticketId, entries] of Object.entries(ticketGroups)) {
const ticketTitle = entries[0]?.ticket_title || `Ticket ${ticketId}`;
const totalHours = entries.reduce((sum, e) => sum + e.hours_worked, 0);
if (totalHours > 20) {
insights.push({
type: 'warning',
category: 'performance',
title: `Time Intensive: ${ticketTitle}`,
description: `${Number(totalHours).toFixed(1)} hours logged for this ticket`,
recommendation: 'Review if ticket scope is appropriate or needs to be split',
severity: 'medium',
actionable: true,
});
}
}
return insights;
}
/**
* Generate key moment events from related entities
*/
private async generateKeyMomentEvents(timeEntries: TimeEntry[]): Promise<TimelineEvent[]> {
const events: TimelineEvent[] = [];
// Get ticket creation and resolution dates
const ticketIds = [...new Set(timeEntries.map(te => te.ticket_id).filter((id): id is number => id != null))];
if (ticketIds.length > 0) {
const tickets = await this.getTickets(ticketIds);
for (const ticket of tickets) {
// Ticket creation
if (ticket.create_date) {
events.push({
id: `ticket-created-${ticket.id}`,
type: 'key_moment',
timestamp: new Date(ticket.create_date),
title: `Ticket Created: ${ticket.ticket_number}`,
description: `Ticket "${ticket.title}" was created`,
metadata: {
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
ticketTitle: ticket.title,
},
isHumanActivity: false,
importance: 'high',
});
}
// Ticket resolution
if (ticket.completed_date) {
events.push({
id: `ticket-resolved-${ticket.id}`,
type: 'milestone',
timestamp: new Date(ticket.completed_date),
title: `Ticket Resolved: ${ticket.ticket_number}`,
description: `Ticket "${ticket.title}" was resolved`,
metadata: {
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
ticketTitle: ticket.title,
},
isHumanActivity: false,
importance: 'critical',
});
}
}
}
return events;
}
/**
* Helper methods
*/
private async getResources(resourceIds: number[]) {
const result = await postgresClient.query(
'SELECT id, first_name, last_name FROM resources WHERE id = ANY($1)',
[resourceIds]
);
return result.rows;
}
private async getTickets(ticketIds: number[]) {
const result = await postgresClient.query(
'SELECT id, ticket_number, title, create_date, completed_date FROM tickets WHERE id = ANY($1)',
[ticketIds]
);
return result.rows;
}
private async getTasks(taskIds: number[]) {
const result = await postgresClient.query(
'SELECT id, title FROM tasks WHERE id = ANY($1)',
[taskIds]
);
return result.rows;
}
private async getProjects(projectIds: number[]) {
const result = await postgresClient.query(
'SELECT id, project_name FROM projects WHERE id = ANY($1)',
[projectIds]
);
return result.rows;
}
private async getCompanies(companyIds: number[]) {
const result = await postgresClient.query(
'SELECT id, company_name FROM companies WHERE id = ANY($1)',
[companyIds]
);
return result.rows;
}
private generateEventDescription(entry: EnrichedTimeEntry): string {
const parts: string[] = [];
if (entry.resource_name) {
parts.push(`${entry.resource_name} worked`);
}
parts.push(`${entry.hours_worked} hours`);
if (entry.ticket_title) {
parts.push(`on "${entry.ticket_title}"`);
}
if (entry.project_name) {
parts.push(`for project "${entry.project_name}"`);
}
if (entry.notes) {
parts.push(`- ${entry.notes}`);
}
return parts.join(' ');
}
private determineImportance(entry: EnrichedTimeEntry): 'low' | 'medium' | 'high' | 'critical' {
if (entry.hours_worked > 8) return 'high';
if (entry.billable && entry.hours_worked > 4) return 'high';
if (entry.billable) return 'medium';
if (entry.hours_worked > 2) return 'medium';
return 'low';
}
private groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
return array.reduce((groups, item) => {
const groupKey = String(item[key]);
if (!groups[groupKey]) {
groups[groupKey] = [];
}
groups[groupKey].push(item);
return groups;
}, {} as Record<string, T[]>);
}
}
// Create singleton instance
export const analyticsIntegration = new AnalyticsIntegrationService();