Files
breakpilot-compliance/ai-compliance-sdk/internal/whistleblower/store.go
Benjamin Boenisch 504dd3591b feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends
Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:11:27 +01:00

592 lines
16 KiB
Go

package whistleblower
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles whistleblower data persistence
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new whistleblower store
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// Report CRUD Operations
// ============================================================================
// CreateReport creates a new whistleblower report with auto-generated reference number and access key
func (s *Store) CreateReport(ctx context.Context, report *Report) error {
report.ID = uuid.New()
now := time.Now().UTC()
report.CreatedAt = now
report.UpdatedAt = now
report.ReceivedAt = now
report.DeadlineAcknowledgment = now.AddDate(0, 0, 7) // 7 days per HinSchG
report.DeadlineFeedback = now.AddDate(0, 3, 0) // 3 months per HinSchG
if report.Status == "" {
report.Status = ReportStatusNew
}
// Generate access key
report.AccessKey = generateAccessKey()
// Generate reference number
year := now.Year()
seq, err := s.GetNextSequenceNumber(ctx, report.TenantID, year)
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
report.ReferenceNumber = generateReferenceNumber(year, seq)
// Initialize audit trail
if report.AuditTrail == nil {
report.AuditTrail = []AuditEntry{}
}
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_created",
UserID: "system",
Details: "Report submitted",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
INSERT INTO whistleblower_reports (
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
) VALUES (
$1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22
)
`,
report.ID, report.TenantID, report.ReferenceNumber, report.AccessKey,
string(report.Category), string(report.Status), report.Title, report.Description,
report.IsAnonymous, report.ReporterName, report.ReporterEmail, report.ReporterPhone,
report.ReceivedAt, report.DeadlineAcknowledgment, report.DeadlineFeedback,
report.AcknowledgedAt, report.ClosedAt, report.AssignedTo,
auditTrailJSON, report.Resolution,
report.CreatedAt, report.UpdatedAt,
)
return err
}
// GetReport retrieves a report by ID
func (s *Store) GetReport(ctx context.Context, id uuid.UUID) (*Report, error) {
var report Report
var category, status string
var auditTrailJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE id = $1
`, id).Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
return &report, nil
}
// GetReportByAccessKey retrieves a report by its access key (for public anonymous access)
func (s *Store) GetReportByAccessKey(ctx context.Context, accessKey string) (*Report, error) {
var id uuid.UUID
err := s.pool.QueryRow(ctx,
"SELECT id FROM whistleblower_reports WHERE access_key = $1",
accessKey,
).Scan(&id)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return s.GetReport(ctx, id)
}
// ListReports lists reports for a tenant with optional filters
func (s *Store) ListReports(ctx context.Context, tenantID uuid.UUID, filters *ReportFilters) ([]Report, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1"
countArgs := []interface{}{tenantID}
countArgIdx := 2
if filters != nil {
if filters.Status != "" {
countQuery += fmt.Sprintf(" AND status = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Status))
countArgIdx++
}
if filters.Category != "" {
countQuery += fmt.Sprintf(" AND category = $%d", countArgIdx)
countArgs = append(countArgs, string(filters.Category))
countArgIdx++
}
}
var total int
err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, 0, err
}
// Build data query
query := `
SELECT
id, tenant_id, reference_number, access_key,
category, status, title, description,
is_anonymous, reporter_name, reporter_email, reporter_phone,
received_at, deadline_acknowledgment, deadline_feedback,
acknowledged_at, closed_at, assigned_to,
audit_trail, resolution,
created_at, updated_at
FROM whistleblower_reports WHERE tenant_id = $1`
args := []interface{}{tenantID}
argIdx := 2
if filters != nil {
if filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(filters.Status))
argIdx++
}
if filters.Category != "" {
query += fmt.Sprintf(" AND category = $%d", argIdx)
args = append(args, string(filters.Category))
argIdx++
}
}
query += " ORDER BY created_at DESC"
if filters != nil && filters.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filters.Limit)
argIdx++
if filters.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filters.Offset)
argIdx++
}
}
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var reports []Report
for rows.Next() {
var report Report
var category, status string
var auditTrailJSON []byte
err := rows.Scan(
&report.ID, &report.TenantID, &report.ReferenceNumber, &report.AccessKey,
&category, &status, &report.Title, &report.Description,
&report.IsAnonymous, &report.ReporterName, &report.ReporterEmail, &report.ReporterPhone,
&report.ReceivedAt, &report.DeadlineAcknowledgment, &report.DeadlineFeedback,
&report.AcknowledgedAt, &report.ClosedAt, &report.AssignedTo,
&auditTrailJSON, &report.Resolution,
&report.CreatedAt, &report.UpdatedAt,
)
if err != nil {
return nil, 0, err
}
report.Category = ReportCategory(category)
report.Status = ReportStatus(status)
json.Unmarshal(auditTrailJSON, &report.AuditTrail)
// Do not expose access key in list responses
report.AccessKey = ""
reports = append(reports, report)
}
return reports, total, nil
}
// UpdateReport updates a report
func (s *Store) UpdateReport(ctx context.Context, report *Report) error {
report.UpdatedAt = time.Now().UTC()
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
category = $2, status = $3, title = $4, description = $5,
assigned_to = $6, audit_trail = $7, resolution = $8,
updated_at = $9
WHERE id = $1
`,
report.ID,
string(report.Category), string(report.Status), report.Title, report.Description,
report.AssignedTo, auditTrailJSON, report.Resolution,
report.UpdatedAt,
)
return err
}
// AcknowledgeReport acknowledges a report, setting acknowledged_at and adding an audit entry
func (s *Store) AcknowledgeReport(ctx context.Context, id uuid.UUID, userID uuid.UUID) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.AcknowledgedAt = &now
report.Status = ReportStatusAcknowledged
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_acknowledged",
UserID: userID.String(),
Details: "Report acknowledged within HinSchG deadline",
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, acknowledged_at = $3,
audit_trail = $4, updated_at = $5
WHERE id = $1
`,
id, string(ReportStatusAcknowledged), now,
auditTrailJSON, now,
)
return err
}
// CloseReport closes a report with a resolution
func (s *Store) CloseReport(ctx context.Context, id uuid.UUID, userID uuid.UUID, resolution string) error {
report, err := s.GetReport(ctx, id)
if err != nil || report == nil {
return fmt.Errorf("report not found")
}
now := time.Now().UTC()
report.ClosedAt = &now
report.Status = ReportStatusClosed
report.Resolution = resolution
report.UpdatedAt = now
report.AuditTrail = append(report.AuditTrail, AuditEntry{
Timestamp: now,
Action: "report_closed",
UserID: userID.String(),
Details: "Report closed with resolution: " + resolution,
})
auditTrailJSON, _ := json.Marshal(report.AuditTrail)
_, err = s.pool.Exec(ctx, `
UPDATE whistleblower_reports SET
status = $2, closed_at = $3, resolution = $4,
audit_trail = $5, updated_at = $6
WHERE id = $1
`,
id, string(ReportStatusClosed), now, resolution,
auditTrailJSON, now,
)
return err
}
// DeleteReport deletes a report and its related data (cascading via FK)
func (s *Store) DeleteReport(ctx context.Context, id uuid.UUID) error {
_, err := s.pool.Exec(ctx, "DELETE FROM whistleblower_measures WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_messages WHERE report_id = $1", id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, "DELETE FROM whistleblower_reports WHERE id = $1", id)
return err
}
// ============================================================================
// Message Operations
// ============================================================================
// AddMessage adds an anonymous message to a report
func (s *Store) AddMessage(ctx context.Context, msg *AnonymousMessage) error {
msg.ID = uuid.New()
msg.SentAt = time.Now().UTC()
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_messages (
id, report_id, direction, content, sent_at, read_at
) VALUES (
$1, $2, $3, $4, $5, $6
)
`,
msg.ID, msg.ReportID, string(msg.Direction), msg.Content, msg.SentAt, msg.ReadAt,
)
return err
}
// ListMessages lists messages for a report
func (s *Store) ListMessages(ctx context.Context, reportID uuid.UUID) ([]AnonymousMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, direction, content, sent_at, read_at
FROM whistleblower_messages WHERE report_id = $1
ORDER BY sent_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []AnonymousMessage
for rows.Next() {
var msg AnonymousMessage
var direction string
err := rows.Scan(
&msg.ID, &msg.ReportID, &direction, &msg.Content, &msg.SentAt, &msg.ReadAt,
)
if err != nil {
return nil, err
}
msg.Direction = MessageDirection(direction)
messages = append(messages, msg)
}
return messages, nil
}
// ============================================================================
// Measure Operations
// ============================================================================
// AddMeasure adds a corrective measure to a report
func (s *Store) AddMeasure(ctx context.Context, measure *Measure) error {
measure.ID = uuid.New()
measure.CreatedAt = time.Now().UTC()
if measure.Status == "" {
measure.Status = MeasureStatusPlanned
}
_, err := s.pool.Exec(ctx, `
INSERT INTO whistleblower_measures (
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9
)
`,
measure.ID, measure.ReportID, measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt, measure.CreatedAt,
)
return err
}
// ListMeasures lists measures for a report
func (s *Store) ListMeasures(ctx context.Context, reportID uuid.UUID) ([]Measure, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, report_id, title, description, status,
responsible, due_date, completed_at, created_at
FROM whistleblower_measures WHERE report_id = $1
ORDER BY created_at ASC
`, reportID)
if err != nil {
return nil, err
}
defer rows.Close()
var measures []Measure
for rows.Next() {
var m Measure
var status string
err := rows.Scan(
&m.ID, &m.ReportID, &m.Title, &m.Description, &status,
&m.Responsible, &m.DueDate, &m.CompletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.Status = MeasureStatus(status)
measures = append(measures, m)
}
return measures, nil
}
// UpdateMeasure updates a measure
func (s *Store) UpdateMeasure(ctx context.Context, measure *Measure) error {
_, err := s.pool.Exec(ctx, `
UPDATE whistleblower_measures SET
title = $2, description = $3, status = $4,
responsible = $5, due_date = $6, completed_at = $7
WHERE id = $1
`,
measure.ID,
measure.Title, measure.Description, string(measure.Status),
measure.Responsible, measure.DueDate, measure.CompletedAt,
)
return err
}
// ============================================================================
// Statistics
// ============================================================================
// GetStatistics returns aggregated whistleblower statistics for a tenant
func (s *Store) GetStatistics(ctx context.Context, tenantID uuid.UUID) (*WhistleblowerStatistics, error) {
stats := &WhistleblowerStatistics{
ByStatus: make(map[string]int),
ByCategory: make(map[string]int),
}
// Total reports
s.pool.QueryRow(ctx,
"SELECT COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1",
tenantID).Scan(&stats.TotalReports)
// By status
rows, err := s.pool.Query(ctx,
"SELECT status, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY status",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var status string
var count int
rows.Scan(&status, &count)
stats.ByStatus[status] = count
}
}
// By category
rows, err = s.pool.Query(ctx,
"SELECT category, COUNT(*) FROM whistleblower_reports WHERE tenant_id = $1 GROUP BY category",
tenantID)
if err == nil {
defer rows.Close()
for rows.Next() {
var category string
var count int
rows.Scan(&category, &count)
stats.ByCategory[category] = count
}
}
// Overdue acknowledgments: reports past deadline_acknowledgment that haven't been acknowledged
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND acknowledged_at IS NULL
AND status = 'new'
AND deadline_acknowledgment < NOW()
`, tenantID).Scan(&stats.OverdueAcknowledgments)
// Overdue feedbacks: reports past deadline_feedback that are still open
s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM whistleblower_reports
WHERE tenant_id = $1
AND closed_at IS NULL
AND status NOT IN ('closed', 'rejected')
AND deadline_feedback < NOW()
`, tenantID).Scan(&stats.OverdueFeedbacks)
// Average resolution days (for closed reports)
s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (closed_at - received_at)) / 86400), 0)
FROM whistleblower_reports
WHERE tenant_id = $1 AND closed_at IS NOT NULL
`, tenantID).Scan(&stats.AvgResolutionDays)
return stats, nil
}
// ============================================================================
// Sequence Number
// ============================================================================
// GetNextSequenceNumber gets and increments the sequence number for reference number generation
func (s *Store) GetNextSequenceNumber(ctx context.Context, tenantID uuid.UUID, year int) (int, error) {
var seq int
err := s.pool.QueryRow(ctx, `
INSERT INTO whistleblower_sequences (tenant_id, year, last_sequence)
VALUES ($1, $2, 1)
ON CONFLICT (tenant_id, year) DO UPDATE SET
last_sequence = whistleblower_sequences.last_sequence + 1
RETURNING last_sequence
`, tenantID, year).Scan(&seq)
if err != nil {
return 0, err
}
return seq, nil
}