- 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
477 lines
15 KiB
TypeScript
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();
|