package alerts import ( "context" "encoding/json" "errors" "regexp" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrInvalid = errors.New("alert input is invalid") ErrAccessDenied = errors.New("alert operations access denied") ErrNotFound = errors.New("alert resource not found") ErrStateConflict = errors.New("alert state conflict") ErrVersionConflict = errors.New("alert version conflict") ) type RoutingInput struct { Category string `json:"category"` Severity string `json:"severity"` ReceiverGroup string `json:"receiver_group"` ResponseMinutes int `json:"response_minutes"` EscalationAfterMinutes int `json:"escalation_after_minutes"` EscalationReceiverGroup string `json:"escalation_receiver_group"` RunbookRef string `json:"runbook_ref"` ChangeSummary string `json:"change_summary"` } type RoutingPolicy struct { ID string `json:"id"` TenantID string `json:"tenant_id"` Category string `json:"category"` Severity string `json:"severity"` VersionNo int `json:"version_no"` Status string `json:"status"` ReceiverGroup string `json:"receiver_group"` ResponseMinutes int `json:"response_minutes"` EscalationAfterMinutes int `json:"escalation_after_minutes"` EscalationReceiverGroup string `json:"escalation_receiver_group"` RunbookRef string `json:"runbook_ref"` ChangeSummary string `json:"change_summary"` CreatedBy string `json:"created_by"` RetiredAt *time.Time `json:"retired_at,omitempty"` RowVersion int64 `json:"row_version"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type SilenceInput struct { RuleCode string `json:"rule_code,omitempty"` Category string `json:"category,omitempty"` ResourceType string `json:"resource_type,omitempty"` ResourcePublicID string `json:"resource_id,omitempty"` Matcher map[string]any `json:"matcher"` Reason string `json:"reason"` StartsAt time.Time `json:"starts_at"` EndsAt time.Time `json:"ends_at"` } type Silence struct { ID string `json:"id"` RuleCode *string `json:"rule_code,omitempty"` Category *string `json:"category,omitempty"` ResourceType *string `json:"resource_type,omitempty"` ResourcePublicID *string `json:"resource_id,omitempty"` Matcher map[string]any `json:"matcher"` Reason string `json:"reason"` StartsAt time.Time `json:"starts_at"` EndsAt time.Time `json:"ends_at"` CreatedBy string `json:"created_by"` RevokedAt *time.Time `json:"revoked_at,omitempty"` RowVersion int64 `json:"row_version"` CreatedAt time.Time `json:"created_at"` } type Action struct { ID string `json:"id"` ActionType string `json:"action_type"` FromStatus *string `json:"from_status,omitempty"` ToStatus *string `json:"to_status,omitempty"` ActorType string `json:"actor_type"` ActorUser *string `json:"actor_user_id,omitempty"` Note string `json:"note"` CreatedAt time.Time `json:"created_at"` } type Incident struct { ID string `json:"id"` RuleCode string `json:"rule_code"` Category string `json:"category"` Severity string `json:"severity"` Status string `json:"status"` Fingerprint string `json:"fingerprint"` Title string `json:"title"` Summary string `json:"summary"` ResourceType *string `json:"resource_type,omitempty"` ResourcePublicID *string `json:"resource_id,omitempty"` CorrelationRequestID *string `json:"request_id,omitempty"` ReceiverGroup *string `json:"receiver_group,omitempty"` RunbookRef *string `json:"runbook_ref,omitempty"` CurrentOwner *string `json:"current_owner,omitempty"` FirstDetectedAt time.Time `json:"first_detected_at"` LastDetectedAt time.Time `json:"last_detected_at"` OccurrenceCount int64 `json:"occurrence_count"` AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` MitigatedAt *time.Time `json:"mitigated_at,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` SilencedUntil *time.Time `json:"silenced_until,omitempty"` EscalatedAt *time.Time `json:"escalated_at,omitempty"` RowVersion int64 `json:"row_version"` Actions []Action `json:"actions"` } type actor struct{ tenantID, tenantPublicID, userID, userPublicID, role string } type Service struct { pool *pgxpool.Pool audit *audit.Repository idem *idempotency.Executor } func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository) (*Service, error) { if pool == nil || auditRepository == nil { return nil, errors.New("alert service dependencies are required") } return &Service{pool: pool, audit: auditRepository, idem: idempotency.NewExecutor(pool)}, nil } func (s *Service) CreateRoutingPolicy(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input RoutingInput) (RoutingPolicy, bool, error) { normalizeRouting(&input) if !validRoutingInput(input) { return RoutingPolicy{}, false, ErrInvalid } a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_approver", "ops_admin") if err != nil { return RoutingPolicy{}, false, err } body, _ := json.Marshal(input) result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/alert-routing-policies", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) { if _, e := tx.Exec(op, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, a.tenantID+":"+input.Category+":"+input.Severity); e != nil { return idempotency.Result{}, e } var version int if e := tx.QueryRow(op, `SELECT COALESCE(MAX(version_no),0)+1 FROM alert_routing_policy WHERE tenant_id=$1::uuid AND category=$2 AND severity=$3`, a.tenantID, input.Category, input.Severity).Scan(&version); e != nil { return idempotency.Result{}, e } if _, e := tx.Exec(op, `UPDATE alert_routing_policy SET status='RETIRED',retired_at=now(),row_version=row_version+1,updated_at=now() WHERE tenant_id=$1::uuid AND category=$2 AND severity=$3 AND status='ACTIVE'`, a.tenantID, input.Category, input.Severity); e != nil { return idempotency.Result{}, e } publicID := uuid.NewString() var internalID string if e := tx.QueryRow(op, `INSERT INTO alert_routing_policy(public_id,tenant_id,category,severity,version_no,receiver_group,response_minutes,escalation_after_minutes,escalation_receiver_group,runbook_ref,change_summary,created_by) VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::uuid) RETURNING id::text`, publicID, a.tenantID, input.Category, input.Severity, version, input.ReceiverGroup, input.ResponseMinutes, input.EscalationAfterMinutes, input.EscalationReceiverGroup, input.RunbookRef, input.ChangeSummary, a.userID).Scan(&internalID); e != nil { return idempotency.Result{}, e } if e := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.routing.activate", ResourceType: "alert_routing_policy", ResourceID: &internalID, RequestID: requestID, Purpose: input.ChangeSummary, Result: "success", Detail: map[string]any{"category": input.Category, "severity": input.Severity, "version_no": version}}); e != nil { return idempotency.Result{}, e } item, e := getRoutingTx(op, tx, a.tenantID, publicID) if e != nil { return idempotency.Result{}, e } encoded, _ := json.Marshal(item) return idempotency.Result{StatusCode: 201, Body: encoded, ResourceID: &internalID}, nil }) if err != nil { return RoutingPolicy{}, false, err } var item RoutingPolicy if err = json.Unmarshal(result.Body, &item); err != nil { return RoutingPolicy{}, false, err } return item, !result.Replayed, nil } func (s *Service) ListRoutingPolicies(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]RoutingPolicy, error) { a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if e != nil { return nil, e } rows, e := s.pool.Query(ctx, routingSelect+` WHERE arp.tenant_id=$1::uuid ORDER BY CASE arp.status WHEN 'ACTIVE' THEN 1 ELSE 2 END,arp.category,arp.severity,arp.version_no DESC`, a.tenantID) if e != nil { return nil, e } defer rows.Close() items := []RoutingPolicy{} for rows.Next() { item, se := scanRouting(rows) if se != nil { return nil, se } items = append(items, item) } return items, rows.Err() } func (s *Service) ListIncidents(ctx context.Context, principal identity.Principal, tenantPublicID, status string) ([]Incident, error) { a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if e != nil { return nil, e } status = strings.ToUpper(strings.TrimSpace(status)) if status != "" && !validStatus(status) { return nil, ErrInvalid } rows, e := s.pool.Query(ctx, incidentSelect+` WHERE ai.tenant_id=$1::uuid AND (NULLIF($2,'') IS NULL OR ai.status=$2) ORDER BY CASE ai.severity WHEN 'CRITICAL' THEN 1 WHEN 'HIGH' THEN 2 WHEN 'MEDIUM' THEN 3 ELSE 4 END,ai.last_detected_at DESC LIMIT 1000`, a.tenantID, status) if e != nil { return nil, e } defer rows.Close() items := []Incident{} for rows.Next() { item, se := scanIncident(rows) if se != nil { return nil, se } items = append(items, item) } return items, rows.Err() } func (s *Service) GetIncident(ctx context.Context, principal identity.Principal, tenantPublicID, publicID string) (Incident, error) { a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if e != nil { return Incident{}, e } if !validUUID(publicID) { return Incident{}, ErrInvalid } tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly}) if e != nil { return Incident{}, e } defer tx.Rollback(context.Background()) item, e := getIncidentTx(ctx, tx, a.tenantID, publicID) if errors.Is(e, pgx.ErrNoRows) { return Incident{}, ErrNotFound } if e == nil { e = tx.Commit(ctx) } return item, e } func (s *Service) CreateSilence(ctx context.Context, principal identity.Principal, tenantPublicID, key, requestID string, input SilenceInput) (Silence, bool, error) { normalizeSilence(&input) if !validSilenceInput(input, time.Now().UTC()) { return Silence{}, false, ErrInvalid } a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin") if e != nil { return Silence{}, false, e } encoded, _ := json.Marshal(input) result, e := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/alert-silences", Key: key, RequestHash: idempotency.Hash(encoded), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) { matcher, _ := json.Marshal(input.Matcher) publicID := uuid.NewString() var internalID string e := tx.QueryRow(op, `INSERT INTO alert_silence(public_id,tenant_id,rule_code,category,resource_type,resource_public_id,matcher,reason,starts_at,ends_at,created_by) VALUES($1::uuid,$2::uuid,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),$6::uuid,$7::jsonb,$8,$9,$10,$11::uuid) RETURNING id::text`, publicID, a.tenantID, input.RuleCode, input.Category, input.ResourceType, nullStringUUID(input.ResourcePublicID), matcher, input.Reason, input.StartsAt.UTC(), input.EndsAt.UTC(), a.userID).Scan(&internalID) if e != nil { return idempotency.Result{}, e } if e = s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.silence.create", ResourceType: "alert_silence", ResourceID: &internalID, RequestID: requestID, Purpose: input.Reason, Result: "success"}); e != nil { return idempotency.Result{}, e } item, e := getSilenceTx(op, tx, a.tenantID, publicID) if e != nil { return idempotency.Result{}, e } body, _ := json.Marshal(item) return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &internalID}, nil }) if e != nil { return Silence{}, false, e } var item Silence if e = json.Unmarshal(result.Body, &item); e != nil { return Silence{}, false, e } return item, !result.Replayed, nil } func (s *Service) ListSilences(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Silence, error) { a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if e != nil { return nil, e } rows, e := s.pool.Query(ctx, silenceSelect+` WHERE als.tenant_id=$1::uuid ORDER BY CASE WHEN revoked_at IS NULL AND ends_at>now() THEN 1 ELSE 2 END,ends_at DESC LIMIT 1000`, a.tenantID) if e != nil { return nil, e } defer rows.Close() items := []Silence{} for rows.Next() { item, se := scanSilence(rows) if se != nil { return nil, se } items = append(items, item) } return items, rows.Err() } func (s *Service) RevokeSilence(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID, reason string, expected int64) (Silence, error) { reason = strings.TrimSpace(reason) if reason == "" || len([]rune(reason)) > 500 { return Silence{}, ErrInvalid } a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin") if e != nil { return Silence{}, e } tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{}) if e != nil { return Silence{}, e } defer tx.Rollback(context.Background()) var id string var version int64 var revoked *time.Time e = tx.QueryRow(ctx, `SELECT id::text,row_version,revoked_at FROM alert_silence WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, publicID).Scan(&id, &version, &revoked) if errors.Is(e, pgx.ErrNoRows) { return Silence{}, ErrNotFound } if e != nil { return Silence{}, e } if version != expected { return Silence{}, ErrVersionConflict } if revoked != nil { return Silence{}, ErrStateConflict } _, e = tx.Exec(ctx, `UPDATE alert_silence SET revoked_by=$2::uuid,revoked_at=now(),row_version=row_version+1,updated_at=now() WHERE id=$1::uuid`, id, a.userID) if e != nil { return Silence{}, e } if e = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.silence.revoke", ResourceType: "alert_silence", ResourceID: &id, RequestID: requestID, Purpose: reason, Result: "success"}); e != nil { return Silence{}, e } item, e := getSilenceTx(ctx, tx, a.tenantID, publicID) if e == nil { e = tx.Commit(ctx) } return item, e } func (s *Service) TransitionIncident(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID, action, note string, expected int64) (Incident, error) { action = strings.ToUpper(strings.TrimSpace(action)) note = strings.TrimSpace(note) if note == "" || len([]rune(note)) > 1000 { return Incident{}, ErrInvalid } targets := map[string]string{"ACKNOWLEDGE": "ACKNOWLEDGED", "MITIGATE": "MITIGATED", "RESOLVE": "RESOLVED"} target, ok := targets[action] if !ok { return Incident{}, ErrInvalid } a, e := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin") if e != nil { return Incident{}, e } tx, e := s.pool.BeginTx(ctx, pgx.TxOptions{}) if e != nil { return Incident{}, e } defer tx.Rollback(context.Background()) var id, status string var version int64 e = tx.QueryRow(ctx, `SELECT id::text,status,row_version FROM alert_incident WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, publicID).Scan(&id, &status, &version) if errors.Is(e, pgx.ErrNoRows) { return Incident{}, ErrNotFound } if e != nil { return Incident{}, e } if version != expected { return Incident{}, ErrVersionConflict } if status == "RESOLVED" || status == target { return Incident{}, ErrStateConflict } now := time.Now().UTC() _, e = tx.Exec(ctx, `UPDATE alert_incident SET status=$2::varchar,current_owner_id=COALESCE(current_owner_id,$3::uuid),acknowledged_at=CASE WHEN $2::varchar='ACKNOWLEDGED' THEN $4::timestamptz ELSE acknowledged_at END,mitigated_at=CASE WHEN $2::varchar='MITIGATED' THEN $4::timestamptz ELSE mitigated_at END,resolved_at=CASE WHEN $2::varchar='RESOLVED' THEN $4::timestamptz ELSE resolved_at END,row_version=row_version+1,updated_at=$4::timestamptz WHERE id=$1::uuid`, id, target, a.userID, now) if e != nil { return Incident{}, e } _, e = tx.Exec(ctx, `INSERT INTO alert_action(tenant_id,incident_id,action_type,from_status,to_status,actor_type,actor_user_id,note) VALUES($1::uuid,$2::uuid,$3,$4,$5,'user',$6::uuid,$7)`, a.tenantID, id, target, status, target, a.userID, note) if e != nil { return Incident{}, e } if e = s.audit.Record(ctx, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.incident." + strings.ToLower(action), ResourceType: "alert_incident", ResourceID: &id, RequestID: requestID, Purpose: note, Result: "success"}); e != nil { return Incident{}, e } item, e := getIncidentTx(ctx, tx, a.tenantID, publicID) if e == nil { e = tx.Commit(ctx) } return item, e } func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string, roles ...string) (actor, error) { if _, e := uuid.Parse(tenantPublicID); e != nil { return actor{}, ErrInvalid } var a actor e := s.pool.QueryRow(ctx, `SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND ua.status='active' AND ua.account_kind='operator'`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&a.tenantID, &a.tenantPublicID, &a.userID, &a.userPublicID, &a.role) if errors.Is(e, pgx.ErrNoRows) { return actor{}, ErrAccessDenied } if e != nil { return actor{}, e } for _, role := range roles { if a.role == role { return a, nil } } return actor{}, ErrAccessDenied } func normalizeRouting(i *RoutingInput) { i.Category = strings.ToUpper(strings.TrimSpace(i.Category)) i.Severity = strings.ToUpper(strings.TrimSpace(i.Severity)) i.ReceiverGroup = strings.TrimSpace(i.ReceiverGroup) i.EscalationReceiverGroup = strings.TrimSpace(i.EscalationReceiverGroup) i.RunbookRef = strings.TrimSpace(i.RunbookRef) i.ChangeSummary = strings.TrimSpace(i.ChangeSummary) } func validRoutingInput(i RoutingInput) bool { return validCategory(i.Category) && validSeverity(i.Severity) && i.ReceiverGroup != "" && len([]rune(i.ReceiverGroup)) <= 120 && i.ResponseMinutes >= 1 && i.ResponseMinutes <= 10080 && i.EscalationAfterMinutes >= i.ResponseMinutes && i.EscalationAfterMinutes <= 10080 && i.EscalationReceiverGroup != "" && len([]rune(i.EscalationReceiverGroup)) <= 120 && i.RunbookRef != "" && len([]rune(i.RunbookRef)) <= 300 && i.ChangeSummary != "" && len([]rune(i.ChangeSummary)) <= 500 } func normalizeSilence(i *SilenceInput) { i.RuleCode = strings.ToUpper(strings.TrimSpace(i.RuleCode)) i.Category = strings.ToUpper(strings.TrimSpace(i.Category)) i.ResourceType = strings.TrimSpace(i.ResourceType) i.ResourcePublicID = strings.TrimSpace(i.ResourcePublicID) i.Reason = strings.TrimSpace(i.Reason) } func validSilenceInput(i SilenceInput, now time.Time) bool { return !(i.RuleCode == "" && i.Category == "" && i.ResourcePublicID == "") && (i.RuleCode == "" || rulePattern.MatchString(i.RuleCode)) && (i.Category == "" || validCategory(i.Category)) && (i.ResourcePublicID == "" || validUUID(i.ResourcePublicID)) && i.Reason != "" && len([]rune(i.Reason)) <= 500 && !i.StartsAt.IsZero() && i.EndsAt.After(i.StartsAt) && i.EndsAt.Sub(i.StartsAt) <= 30*24*time.Hour && !i.EndsAt.Before(now) } func validCategory(v string) bool { return v == "BUSINESS" || v == "TECHNICAL" || v == "SECURITY" || v == "COST" } func validSeverity(v string) bool { return v == "CRITICAL" || v == "HIGH" || v == "MEDIUM" || v == "LOW" } func validStatus(v string) bool { return v == "OPEN" || v == "ACKNOWLEDGED" || v == "MITIGATED" || v == "RESOLVED" || v == "SILENCED" } func validUUID(v string) bool { _, e := uuid.Parse(v); return e == nil } func nullStringUUID(v string) any { if strings.TrimSpace(v) == "" { return nil } return v } var rulePattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{2,99}$`) type scanner interface{ Scan(...any) error } const routingSelect = `SELECT arp.public_id::text,t.public_id::text,arp.category,arp.severity,arp.version_no,arp.status,arp.receiver_group,arp.response_minutes,arp.escalation_after_minutes,arp.escalation_receiver_group,arp.runbook_ref,arp.change_summary,ua.public_id::text,arp.retired_at,arp.row_version,arp.created_at,arp.updated_at FROM alert_routing_policy arp JOIN tenant t ON t.id=arp.tenant_id JOIN user_account ua ON ua.id=arp.created_by` func scanRouting(r scanner) (RoutingPolicy, error) { var i RoutingPolicy e := r.Scan(&i.ID, &i.TenantID, &i.Category, &i.Severity, &i.VersionNo, &i.Status, &i.ReceiverGroup, &i.ResponseMinutes, &i.EscalationAfterMinutes, &i.EscalationReceiverGroup, &i.RunbookRef, &i.ChangeSummary, &i.CreatedBy, &i.RetiredAt, &i.RowVersion, &i.CreatedAt, &i.UpdatedAt) return i, e } func getRoutingTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (RoutingPolicy, error) { return scanRouting(tx.QueryRow(ctx, routingSelect+` WHERE arp.tenant_id=$1::uuid AND arp.public_id=$2::uuid`, tenantID, publicID)) } const incidentSelect = `SELECT ai.public_id::text,ai.rule_code,ai.category,ai.severity,ai.status,ai.fingerprint,ai.title,ai.summary,ai.resource_type,ai.resource_public_id::text,ai.correlation_request_id,arp.receiver_group,arp.runbook_ref,owner.public_id::text,ai.first_detected_at,ai.last_detected_at,ai.occurrence_count,ai.acknowledged_at,ai.mitigated_at,ai.resolved_at,ai.silenced_until,ai.escalated_at,ai.row_version FROM alert_incident ai LEFT JOIN alert_routing_policy arp ON arp.id=ai.routing_policy_id LEFT JOIN user_account owner ON owner.id=ai.current_owner_id` func scanIncident(r scanner) (Incident, error) { var i Incident e := r.Scan(&i.ID, &i.RuleCode, &i.Category, &i.Severity, &i.Status, &i.Fingerprint, &i.Title, &i.Summary, &i.ResourceType, &i.ResourcePublicID, &i.CorrelationRequestID, &i.ReceiverGroup, &i.RunbookRef, &i.CurrentOwner, &i.FirstDetectedAt, &i.LastDetectedAt, &i.OccurrenceCount, &i.AcknowledgedAt, &i.MitigatedAt, &i.ResolvedAt, &i.SilencedUntil, &i.EscalatedAt, &i.RowVersion) i.Actions = []Action{} return i, e } func getIncidentTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Incident, error) { i, e := scanIncident(tx.QueryRow(ctx, incidentSelect+` WHERE ai.tenant_id=$1::uuid AND ai.public_id=$2::uuid`, tenantID, publicID)) if e != nil { return i, e } rows, e := tx.Query(ctx, `SELECT aa.public_id::text,aa.action_type,aa.from_status,aa.to_status,aa.actor_type,ua.public_id::text,aa.note,aa.created_at FROM alert_action aa LEFT JOIN user_account ua ON ua.id=aa.actor_user_id WHERE aa.incident_id=(SELECT id FROM alert_incident WHERE tenant_id=$1::uuid AND public_id=$2::uuid) ORDER BY aa.created_at,aa.id`, tenantID, publicID) if e != nil { return i, e } defer rows.Close() for rows.Next() { var a Action if e = rows.Scan(&a.ID, &a.ActionType, &a.FromStatus, &a.ToStatus, &a.ActorType, &a.ActorUser, &a.Note, &a.CreatedAt); e != nil { return i, e } i.Actions = append(i.Actions, a) } return i, rows.Err() } const silenceSelect = `SELECT als.public_id::text,als.rule_code,als.category,als.resource_type,als.resource_public_id::text,als.matcher,als.reason,als.starts_at,als.ends_at,ua.public_id::text,als.revoked_at,als.row_version,als.created_at FROM alert_silence als JOIN user_account ua ON ua.id=als.created_by` func scanSilence(r scanner) (Silence, error) { var i Silence var raw []byte e := r.Scan(&i.ID, &i.RuleCode, &i.Category, &i.ResourceType, &i.ResourcePublicID, &raw, &i.Reason, &i.StartsAt, &i.EndsAt, &i.CreatedBy, &i.RevokedAt, &i.RowVersion, &i.CreatedAt) if e == nil { e = json.Unmarshal(raw, &i.Matcher) } if i.Matcher == nil { i.Matcher = map[string]any{} } return i, e } func getSilenceTx(ctx context.Context, tx pgx.Tx, tenantID, publicID string) (Silence, error) { return scanSilence(tx.QueryRow(ctx, silenceSelect+` WHERE als.tenant_id=$1::uuid AND als.public_id=$2::uuid`, tenantID, publicID)) }