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 }