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 } report.AccessKey = generateAccessKey() 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) 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) { 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 } 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) 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 }