Split 7 files exceeding the 500 LOC hard cap into 16 files, all under 500 LOC. No exported symbols renamed; zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
10 KiB
Go
358 lines
10 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
|
|
}
|
|
|
|
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
|
|
}
|