All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
The reporting module imported packages deleted in the previous commit. Replaced with direct SQL queries against the compliance schema tables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
517 lines
16 KiB
Go
517 lines
16 KiB
Go
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
|
|
}
|