package reporting import ( "context" "math" "sort" "time" "github.com/breakpilot/ai-compliance-sdk/internal/academy" "github.com/breakpilot/ai-compliance-sdk/internal/whistleblower" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type Store struct { pool *pgxpool.Pool whistleStore *whistleblower.Store academyStore *academy.Store } func NewStore(pool *pgxpool.Pool, ws *whistleblower.Store, as *academy.Store) *Store { return &Store{ pool: pool, whistleStore: ws, academyStore: as, } } func (s *Store) GenerateReport(ctx context.Context, tenantID uuid.UUID) (*ExecutiveReport, error) { report := &ExecutiveReport{ GeneratedAt: time.Now().UTC(), TenantID: tenantID.String(), } // 1. Gather DSGVO stats via direct SQL (Python is now primary for DSGVO) report.DSGVO = s.getDSGVOStats(ctx, tenantID) // 2. Gather vendor stats via direct SQL (Python is now primary for vendors) report.Vendors = s.getVendorStats(ctx, tenantID) // 3. Gather incident stats via direct SQL (Python is now primary for incidents) report.Incidents = s.getIncidentStats(ctx, tenantID) // 4. Gather whistleblower stats whistleStats, err := s.whistleStore.GetStatistics(ctx, tenantID) if err == nil && whistleStats != nil { openReports := 0 for status, count := range whistleStats.ByStatus { if status != "CLOSED" && status != "ARCHIVED" { openReports += count } } report.Whistleblower = WhistleblowerSummary{ TotalReports: whistleStats.TotalReports, OpenReports: openReports, OverdueAcknowledgments: whistleStats.OverdueAcknowledgments, OverdueFeedbacks: whistleStats.OverdueFeedbacks, AvgResolutionDays: whistleStats.AvgResolutionDays, } } // 5. Gather academy stats academyStats, err := s.academyStore.GetStatistics(ctx, tenantID) if err == nil && academyStats != nil { report.Academy = AcademySummary{ TotalCourses: academyStats.TotalCourses, TotalEnrollments: academyStats.TotalEnrollments, CompletionRate: academyStats.CompletionRate, OverdueCount: academyStats.OverdueCount, AvgCompletionDays: academyStats.AvgCompletionDays, } } // 6. Calculate risk overview report.RiskOverview = s.calculateRiskOverview(report) // 7. Calculate compliance score (0-100) report.ComplianceScore = s.calculateComplianceScore(report) // 8. Gather upcoming deadlines from DB report.UpcomingDeadlines = s.getUpcomingDeadlines(ctx, tenantID) // 9. Gather recent activity from DB report.RecentActivity = s.getRecentActivity(ctx, tenantID) return report, nil } // getDSGVOStats queries DSGVO tables directly (previously via dsgvo.Store) func (s *Store) getDSGVOStats(ctx context.Context, tenantID uuid.UUID) DSGVOSummary { summary := DSGVOSummary{} // Processing activities _ = s.pool.QueryRow(ctx, `SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'ACTIVE') FROM compliance.vvt_entries WHERE tenant_id = $1`, tenantID, ).Scan(&summary.ProcessingActivities, &summary.ActiveProcessings) // TOMs _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE status = 'IMPLEMENTED'), COUNT(*) FILTER (WHERE status = 'PLANNED') FROM compliance.tom_entries WHERE tenant_id = $1`, tenantID, ).Scan(&summary.TOMsImplemented, &summary.TOMsPlanned) summary.TOMsTotal = summary.TOMsImplemented + summary.TOMsPlanned if summary.TOMsTotal > 0 { summary.CompletionPercent = int(math.Round(float64(summary.TOMsImplemented) / float64(summary.TOMsTotal) * 100)) } // DSRs _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE status NOT IN ('COMPLETED','REJECTED')), COUNT(*) FILTER (WHERE deadline < NOW() AND status NOT IN ('COMPLETED','REJECTED')) FROM compliance.dsr_requests WHERE tenant_id = $1`, tenantID, ).Scan(&summary.OpenDSRs, &summary.OverdueDSRs) // DSFAs _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM compliance.dsfa_entries WHERE tenant_id = $1 AND status = 'COMPLETED'`, tenantID, ).Scan(&summary.DSFAsCompleted) // Retention policies _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM compliance.loeschfristen WHERE tenant_id = $1`, tenantID, ).Scan(&summary.RetentionPolicies) return summary } // getVendorStats queries vendor tables directly (previously via vendor.Store) func (s *Store) getVendorStats(ctx context.Context, tenantID uuid.UUID) VendorSummary { summary := VendorSummary{ByRiskLevel: map[string]int{}} _ = s.pool.QueryRow(ctx, `SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'ACTIVE') FROM compliance.vendor_compliance WHERE tenant_id = $1`, tenantID, ).Scan(&summary.TotalVendors, &summary.ActiveVendors) rows, err := s.pool.Query(ctx, `SELECT COALESCE(risk_level, 'UNKNOWN'), COUNT(*) FROM compliance.vendor_compliance WHERE tenant_id = $1 GROUP BY risk_level`, tenantID, ) if err == nil { defer rows.Close() for rows.Next() { var level string var count int if rows.Scan(&level, &count) == nil { summary.ByRiskLevel[level] = count } } } _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM compliance.vendor_compliance WHERE tenant_id = $1 AND next_review_date < NOW()`, tenantID, ).Scan(&summary.PendingReviews) _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM compliance.vendor_compliance WHERE tenant_id = $1 AND contract_end < NOW()`, tenantID, ).Scan(&summary.ExpiredContracts) return summary } // getIncidentStats queries incident tables directly (previously via incidents.Store) func (s *Store) getIncidentStats(ctx context.Context, tenantID uuid.UUID) IncidentSummary { summary := IncidentSummary{} _ = s.pool.QueryRow(ctx, `SELECT COUNT(*), COUNT(*) FILTER (WHERE status NOT IN ('RESOLVED','CLOSED')), COUNT(*) FILTER (WHERE severity = 'CRITICAL' AND status NOT IN ('RESOLVED','CLOSED')) FROM compliance.incidents WHERE tenant_id = $1`, tenantID, ).Scan(&summary.TotalIncidents, &summary.OpenIncidents, &summary.CriticalIncidents) _ = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM compliance.incidents WHERE tenant_id = $1 AND notification_required = true AND notification_sent = false`, tenantID, ).Scan(&summary.NotificationsPending) _ = s.pool.QueryRow(ctx, `SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (resolved_at - created_at))/3600), 0) FROM compliance.incidents WHERE tenant_id = $1 AND resolved_at IS NOT NULL`, tenantID, ).Scan(&summary.AvgResolutionHours) return summary } func (s *Store) calculateRiskOverview(report *ExecutiveReport) RiskOverview { modules := []ModuleRisk{} // DSGVO risk based on overdue DSRs and missing TOMs dsgvoScore := 100 dsgvoIssues := report.DSGVO.OverdueDSRs + report.DSGVO.TOMsPlanned if report.DSGVO.OverdueDSRs > 0 { dsgvoScore -= report.DSGVO.OverdueDSRs * 15 } if report.DSGVO.TOMsTotal > 0 { dsgvoScore = int(math.Round(float64(report.DSGVO.CompletionPercent))) } if dsgvoScore < 0 { dsgvoScore = 0 } modules = append(modules, ModuleRisk{Module: "DSGVO", Level: riskLevel(dsgvoScore), Score: dsgvoScore, Issues: dsgvoIssues}) // Vendor risk based on high-risk vendors and pending reviews vendorScore := 100 vendorIssues := report.Vendors.PendingReviews + report.Vendors.ExpiredContracts highRisk := 0 if v, ok := report.Vendors.ByRiskLevel["HIGH"]; ok { highRisk += v } if v, ok := report.Vendors.ByRiskLevel["CRITICAL"]; ok { highRisk += v } if report.Vendors.TotalVendors > 0 { vendorScore = 100 - int(math.Round(float64(highRisk)/float64(report.Vendors.TotalVendors)*100)) } vendorScore -= report.Vendors.PendingReviews * 5 vendorScore -= report.Vendors.ExpiredContracts * 10 if vendorScore < 0 { vendorScore = 0 } modules = append(modules, ModuleRisk{Module: "Vendors", Level: riskLevel(vendorScore), Score: vendorScore, Issues: vendorIssues}) // Incident risk incidentScore := 100 incidentIssues := report.Incidents.OpenIncidents incidentScore -= report.Incidents.CriticalIncidents * 20 incidentScore -= report.Incidents.OpenIncidents * 5 incidentScore -= report.Incidents.NotificationsPending * 15 if incidentScore < 0 { incidentScore = 0 } modules = append(modules, ModuleRisk{Module: "Incidents", Level: riskLevel(incidentScore), Score: incidentScore, Issues: incidentIssues}) // Whistleblower compliance whistleScore := 100 whistleIssues := report.Whistleblower.OverdueAcknowledgments + report.Whistleblower.OverdueFeedbacks whistleScore -= report.Whistleblower.OverdueAcknowledgments * 20 whistleScore -= report.Whistleblower.OverdueFeedbacks * 10 if whistleScore < 0 { whistleScore = 0 } modules = append(modules, ModuleRisk{Module: "Whistleblower", Level: riskLevel(whistleScore), Score: whistleScore, Issues: whistleIssues}) // Academy compliance academyScore := int(math.Round(report.Academy.CompletionRate)) academyIssues := report.Academy.OverdueCount modules = append(modules, ModuleRisk{Module: "Academy", Level: riskLevel(academyScore), Score: academyScore, Issues: academyIssues}) // Overall score is the average across modules totalScore := 0 for _, m := range modules { totalScore += m.Score } if len(modules) > 0 { totalScore = totalScore / len(modules) } totalFindings := 0 criticalFindings := 0 for _, m := range modules { totalFindings += m.Issues if m.Level == "CRITICAL" { criticalFindings += m.Issues } } return RiskOverview{ OverallLevel: riskLevel(totalScore), ModuleRisks: modules, OpenFindings: totalFindings, CriticalFindings: criticalFindings, } } func riskLevel(score int) string { switch { case score >= 75: return "LOW" case score >= 50: return "MEDIUM" case score >= 25: return "HIGH" default: return "CRITICAL" } } func (s *Store) calculateComplianceScore(report *ExecutiveReport) int { scores := []int{} weights := []int{} // DSGVO: weight 30 (most important) if report.DSGVO.TOMsTotal > 0 { scores = append(scores, report.DSGVO.CompletionPercent) } else { scores = append(scores, 0) } weights = append(weights, 30) // Vendor compliance: weight 20 vendorScore := 100 if report.Vendors.TotalVendors > 0 { vendorScore -= report.Vendors.PendingReviews * 10 vendorScore -= report.Vendors.ExpiredContracts * 15 } if vendorScore < 0 { vendorScore = 0 } scores = append(scores, vendorScore) weights = append(weights, 20) // Incident handling: weight 20 incidentScore := 100 incidentScore -= report.Incidents.OpenIncidents * 10 incidentScore -= report.Incidents.NotificationsPending * 20 if incidentScore < 0 { incidentScore = 0 } scores = append(scores, incidentScore) weights = append(weights, 20) // Whistleblower: weight 15 whistleScore := 100 whistleScore -= report.Whistleblower.OverdueAcknowledgments * 25 whistleScore -= report.Whistleblower.OverdueFeedbacks * 15 if whistleScore < 0 { whistleScore = 0 } scores = append(scores, whistleScore) weights = append(weights, 15) // Academy: weight 15 academyScore := int(math.Round(report.Academy.CompletionRate)) scores = append(scores, academyScore) weights = append(weights, 15) totalWeight := 0 weightedSum := 0 for i, sc := range scores { weightedSum += sc * weights[i] totalWeight += weights[i] } if totalWeight == 0 { return 0 } return int(math.Round(float64(weightedSum) / float64(totalWeight))) } func (s *Store) getUpcomingDeadlines(ctx context.Context, tenantID uuid.UUID) []Deadline { deadlines := []Deadline{} now := time.Now().UTC() // Vendor reviews due rows, err := s.pool.Query(ctx, ` SELECT name, next_review_date FROM compliance.vendor_compliance WHERE tenant_id = $1 AND next_review_date IS NOT NULL ORDER BY next_review_date ASC LIMIT 10 `, tenantID) if err == nil { defer rows.Close() for rows.Next() { var name string var dueDate time.Time if err := rows.Scan(&name, &dueDate); err != nil { continue } daysLeft := int(dueDate.Sub(now).Hours() / 24) severity := "INFO" if daysLeft < 0 { severity = "OVERDUE" } else if daysLeft <= 7 { severity = "URGENT" } else if daysLeft <= 30 { severity = "WARNING" } deadlines = append(deadlines, Deadline{ Module: "Vendors", Type: "REVIEW", Description: "Vendor-Review: " + name, DueDate: dueDate, DaysLeft: daysLeft, Severity: severity, }) } } // DSR deadlines (overdue) rows2, err := s.pool.Query(ctx, ` SELECT request_type, deadline FROM compliance.dsr_requests WHERE tenant_id = $1 AND status NOT IN ('COMPLETED', 'REJECTED') AND deadline IS NOT NULL ORDER BY deadline ASC LIMIT 10 `, tenantID) if err == nil { defer rows2.Close() for rows2.Next() { var reqType string var dueDate time.Time if err := rows2.Scan(&reqType, &dueDate); err != nil { continue } daysLeft := int(dueDate.Sub(now).Hours() / 24) severity := "INFO" if daysLeft < 0 { severity = "OVERDUE" } else if daysLeft <= 3 { severity = "URGENT" } else if daysLeft <= 14 { severity = "WARNING" } deadlines = append(deadlines, Deadline{ Module: "DSR", Type: "RESPONSE", Description: "Betroffenenrecht: " + reqType, DueDate: dueDate, DaysLeft: daysLeft, Severity: severity, }) } } // Sort by due date ascending sort.Slice(deadlines, func(i, j int) bool { return deadlines[i].DueDate.Before(deadlines[j].DueDate) }) if len(deadlines) > 15 { deadlines = deadlines[:15] } return deadlines } func (s *Store) getRecentActivity(ctx context.Context, tenantID uuid.UUID) []ActivityEntry { activities := []ActivityEntry{} // Recent vendors created/updated rows, _ := s.pool.Query(ctx, ` SELECT name, created_at, 'CREATED' as action FROM compliance.vendor_compliance WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days' UNION ALL SELECT name, updated_at, 'UPDATED' FROM compliance.vendor_compliance WHERE tenant_id = $1 AND updated_at > created_at AND updated_at > NOW() - INTERVAL '30 days' ORDER BY 2 DESC LIMIT 5 `, tenantID) if rows != nil { defer rows.Close() for rows.Next() { var name, action string var ts time.Time if err := rows.Scan(&name, &ts, &action); err != nil { continue } desc := "Vendor " if action == "CREATED" { desc += "angelegt: " } else { desc += "aktualisiert: " } activities = append(activities, ActivityEntry{ Timestamp: ts, Module: "Vendors", Action: action, Description: desc + name, }) } } // Recent incidents rows2, _ := s.pool.Query(ctx, ` SELECT title, created_at, severity FROM compliance.incidents WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days' ORDER BY created_at DESC LIMIT 5 `, tenantID) if rows2 != nil { defer rows2.Close() for rows2.Next() { var title, severity string var ts time.Time if err := rows2.Scan(&title, &ts, &severity); err != nil { continue } activities = append(activities, ActivityEntry{ Timestamp: ts, Module: "Incidents", Action: "CREATED", Description: "Datenpanne (" + severity + "): " + title, }) } } // Recent whistleblower reports (admin view) rows3, _ := s.pool.Query(ctx, ` SELECT category, created_at FROM whistleblower_reports WHERE tenant_id = $1 AND created_at > NOW() - INTERVAL '30 days' ORDER BY created_at DESC LIMIT 5 `, tenantID) if rows3 != nil { defer rows3.Close() for rows3.Next() { var category string var ts time.Time if err := rows3.Scan(&category, &ts); err != nil { continue } activities = append(activities, ActivityEntry{ Timestamp: ts, Module: "Whistleblower", Action: "REPORT", Description: "Neue Meldung: " + category, }) } } // Sort by timestamp descending (most recent first) sort.Slice(activities, func(i, j int) bool { return activities[i].Timestamp.After(activities[j].Timestamp) }) if len(activities) > 20 { activities = activities[:20] } return activities }