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>
This commit is contained in:
242
ai-compliance-sdk/internal/whistleblower/models.go
Normal file
242
ai-compliance-sdk/internal/whistleblower/models.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package whistleblower
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Constants / Enums
|
||||
// ============================================================================
|
||||
|
||||
// ReportCategory represents the category of a whistleblower report
|
||||
type ReportCategory string
|
||||
|
||||
const (
|
||||
ReportCategoryCorruption ReportCategory = "corruption"
|
||||
ReportCategoryFraud ReportCategory = "fraud"
|
||||
ReportCategoryDataProtection ReportCategory = "data_protection"
|
||||
ReportCategoryDiscrimination ReportCategory = "discrimination"
|
||||
ReportCategoryEnvironment ReportCategory = "environment"
|
||||
ReportCategoryCompetition ReportCategory = "competition"
|
||||
ReportCategoryProductSafety ReportCategory = "product_safety"
|
||||
ReportCategoryTaxEvasion ReportCategory = "tax_evasion"
|
||||
ReportCategoryOther ReportCategory = "other"
|
||||
)
|
||||
|
||||
// ReportStatus represents the status of a whistleblower report
|
||||
type ReportStatus string
|
||||
|
||||
const (
|
||||
ReportStatusNew ReportStatus = "new"
|
||||
ReportStatusAcknowledged ReportStatus = "acknowledged"
|
||||
ReportStatusUnderReview ReportStatus = "under_review"
|
||||
ReportStatusInvestigation ReportStatus = "investigation"
|
||||
ReportStatusMeasuresTaken ReportStatus = "measures_taken"
|
||||
ReportStatusClosed ReportStatus = "closed"
|
||||
ReportStatusRejected ReportStatus = "rejected"
|
||||
)
|
||||
|
||||
// MessageDirection represents the direction of an anonymous message
|
||||
type MessageDirection string
|
||||
|
||||
const (
|
||||
MessageDirectionReporterToAdmin MessageDirection = "reporter_to_admin"
|
||||
MessageDirectionAdminToReporter MessageDirection = "admin_to_reporter"
|
||||
)
|
||||
|
||||
// MeasureStatus represents the status of a corrective measure
|
||||
type MeasureStatus string
|
||||
|
||||
const (
|
||||
MeasureStatusPlanned MeasureStatus = "planned"
|
||||
MeasureStatusInProgress MeasureStatus = "in_progress"
|
||||
MeasureStatusCompleted MeasureStatus = "completed"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Main Entities
|
||||
// ============================================================================
|
||||
|
||||
// Report represents a whistleblower report (Hinweis) per HinSchG
|
||||
type Report struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
ReferenceNumber string `json:"reference_number"` // e.g. "WB-2026-0001"
|
||||
AccessKey string `json:"access_key,omitempty"` // for anonymous access, only returned once
|
||||
|
||||
// Report content
|
||||
Category ReportCategory `json:"category"`
|
||||
Status ReportStatus `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// Reporter info (optional, for non-anonymous reports)
|
||||
IsAnonymous bool `json:"is_anonymous"`
|
||||
ReporterName *string `json:"reporter_name,omitempty"`
|
||||
ReporterEmail *string `json:"reporter_email,omitempty"`
|
||||
ReporterPhone *string `json:"reporter_phone,omitempty"`
|
||||
|
||||
// HinSchG deadlines
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
DeadlineAcknowledgment time.Time `json:"deadline_acknowledgment"` // 7 days from received_at per HinSchG
|
||||
DeadlineFeedback time.Time `json:"deadline_feedback"` // 3 months from received_at per HinSchG
|
||||
|
||||
// Status timestamps
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
|
||||
// Assignment
|
||||
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
|
||||
|
||||
// Resolution
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
|
||||
// Audit trail (stored as JSONB)
|
||||
AuditTrail []AuditEntry `json:"audit_trail"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AnonymousMessage represents a message exchanged between reporter and admin
|
||||
type AnonymousMessage struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ReportID uuid.UUID `json:"report_id"`
|
||||
Direction MessageDirection `json:"direction"`
|
||||
Content string `json:"content"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
}
|
||||
|
||||
// Measure represents a corrective measure taken for a report
|
||||
type Measure struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ReportID uuid.UUID `json:"report_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status MeasureStatus `json:"status"`
|
||||
Responsible string `json:"responsible"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AuditEntry represents an entry in the audit trail
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Action string `json:"action"`
|
||||
UserID string `json:"user_id"`
|
||||
Details string `json:"details"`
|
||||
}
|
||||
|
||||
// WhistleblowerStatistics contains aggregated statistics for a tenant
|
||||
type WhistleblowerStatistics struct {
|
||||
TotalReports int `json:"total_reports"`
|
||||
ByStatus map[string]int `json:"by_status"`
|
||||
ByCategory map[string]int `json:"by_category"`
|
||||
OverdueAcknowledgments int `json:"overdue_acknowledgments"`
|
||||
OverdueFeedbacks int `json:"overdue_feedbacks"`
|
||||
AvgResolutionDays float64 `json:"avg_resolution_days"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
// PublicReportSubmission is the request for submitting a report (NO auth required)
|
||||
type PublicReportSubmission struct {
|
||||
Category ReportCategory `json:"category" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
IsAnonymous bool `json:"is_anonymous"`
|
||||
ReporterName *string `json:"reporter_name,omitempty"`
|
||||
ReporterEmail *string `json:"reporter_email,omitempty"`
|
||||
ReporterPhone *string `json:"reporter_phone,omitempty"`
|
||||
}
|
||||
|
||||
// PublicReportResponse is returned after submitting a report (access_key only shown once!)
|
||||
type PublicReportResponse struct {
|
||||
ReferenceNumber string `json:"reference_number"`
|
||||
AccessKey string `json:"access_key"`
|
||||
}
|
||||
|
||||
// ReportUpdateRequest is the request for updating a report (admin)
|
||||
type ReportUpdateRequest struct {
|
||||
Category ReportCategory `json:"category,omitempty"`
|
||||
Status ReportStatus `json:"status,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
AssignedTo *uuid.UUID `json:"assigned_to,omitempty"`
|
||||
}
|
||||
|
||||
// AcknowledgeRequest is the request for acknowledging a report
|
||||
type AcknowledgeRequest struct {
|
||||
Message string `json:"message,omitempty"` // optional acknowledgment message to reporter
|
||||
}
|
||||
|
||||
// CloseReportRequest is the request for closing a report
|
||||
type CloseReportRequest struct {
|
||||
Resolution string `json:"resolution" binding:"required"`
|
||||
}
|
||||
|
||||
// AddMeasureRequest is the request for adding a corrective measure
|
||||
type AddMeasureRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Responsible string `json:"responsible" binding:"required"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateMeasureRequest is the request for updating a measure
|
||||
type UpdateMeasureRequest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status MeasureStatus `json:"status,omitempty"`
|
||||
Responsible string `json:"responsible,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
}
|
||||
|
||||
// SendMessageRequest is the request for sending an anonymous message
|
||||
type SendMessageRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
}
|
||||
|
||||
// ReportListResponse is the response for listing reports
|
||||
type ReportListResponse struct {
|
||||
Reports []Report `json:"reports"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ReportFilters defines filters for listing reports
|
||||
type ReportFilters struct {
|
||||
Status ReportStatus
|
||||
Category ReportCategory
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// generateAccessKey generates a random 12-character alphanumeric key
|
||||
func generateAccessKey() string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 12)
|
||||
randomBytes := make([]byte, 12)
|
||||
rand.Read(randomBytes)
|
||||
for i := range b {
|
||||
b[i] = charset[int(randomBytes[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// generateReferenceNumber generates a reference number like "WB-2026-0042"
|
||||
func generateReferenceNumber(year int, sequence int) string {
|
||||
return fmt.Sprintf("WB-%d-%04d", year, sequence)
|
||||
}
|
||||
591
ai-compliance-sdk/internal/whistleblower/store.go
Normal file
591
ai-compliance-sdk/internal/whistleblower/store.go
Normal file
@@ -0,0 +1,591 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user