166 lines
6.1 KiB
Go
166 lines
6.1 KiB
Go
package alert_lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Actor struct {
|
|
ID int
|
|
Name, Role string
|
|
}
|
|
type Detail struct {
|
|
Projection Projection `json:"projection"`
|
|
Timeline []Fact `json:"timeline"`
|
|
CanAck bool `json:"canAck"`
|
|
CanClose bool `json:"canClose"`
|
|
}
|
|
type Result struct {
|
|
Detail Detail `json:"detail"`
|
|
Idempotent bool `json:"idempotent"`
|
|
Won bool `json:"won"`
|
|
}
|
|
type Service struct{ DB *gorm.DB }
|
|
|
|
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
|
|
|
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
|
|
if _, err := uuid.Parse(alertID); err != nil {
|
|
return Detail{}, ErrNotFound
|
|
}
|
|
var projection Projection
|
|
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return Detail{}, ErrNotFound
|
|
}
|
|
return Detail{}, err
|
|
}
|
|
facts := make([]Fact, 0)
|
|
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
|
|
return Detail{}, err
|
|
}
|
|
return Detail{Projection: projection, Timeline: facts}, nil
|
|
}
|
|
|
|
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
|
|
if _, err := uuid.Parse(alertID); err != nil {
|
|
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
|
|
return Result{}, ErrNotFound
|
|
}
|
|
now := time.Now().UTC()
|
|
var projection Projection
|
|
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
|
|
})
|
|
if err == nil {
|
|
detail, getErr := s.Get(ctx, alertID)
|
|
return Result{Detail: detail, Won: true}, getErr
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return Result{}, err
|
|
}
|
|
detail, getErr := s.Get(ctx, alertID)
|
|
if getErr != nil {
|
|
return Result{}, getErr
|
|
}
|
|
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
|
|
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
|
|
return Result{Detail: detail, Idempotent: true}, nil
|
|
}
|
|
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
|
|
return Result{Detail: detail}, ErrAlreadyHandled
|
|
}
|
|
|
|
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
|
|
normalized, err := normalizeClose(input)
|
|
if err != nil {
|
|
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
|
|
return Result{}, err
|
|
}
|
|
if _, err = uuid.Parse(alertID); err != nil {
|
|
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
|
|
return Result{}, ErrNotFound
|
|
}
|
|
var projection Projection
|
|
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
|
|
return lockErr
|
|
}
|
|
if projection.Status == StatusClosed {
|
|
return ErrInvalidTransition
|
|
}
|
|
if projection.Status != StatusAcknowledged {
|
|
return ErrInvalidTransition
|
|
}
|
|
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
|
|
return ErrForbidden
|
|
}
|
|
now := time.Now().UTC()
|
|
var note *string
|
|
if normalized.Note != "" {
|
|
note = &normalized.Note
|
|
}
|
|
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
|
|
return updateErr
|
|
}
|
|
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
|
|
})
|
|
if err == nil {
|
|
detail, getErr := s.Get(ctx, alertID)
|
|
return Result{Detail: detail, Won: true}, getErr
|
|
}
|
|
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return Result{}, err
|
|
}
|
|
detail, getErr := s.Get(ctx, alertID)
|
|
if getErr != nil {
|
|
return Result{}, getErr
|
|
}
|
|
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
|
|
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
|
|
return Result{Detail: detail, Idempotent: true}, nil
|
|
}
|
|
reason := "invalid_transition"
|
|
publicErr := ErrInvalidTransition
|
|
if errors.Is(err, ErrForbidden) {
|
|
reason, publicErr = "forbidden", ErrForbidden
|
|
} else if detail.Projection.Status == StatusClosed {
|
|
reason = "conflicting_replay"
|
|
}
|
|
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
|
|
return Result{Detail: detail}, publicErr
|
|
}
|
|
|
|
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
|
|
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
|
|
if projection != nil {
|
|
audit.ObservedStatus = &projection.Status
|
|
audit.ObservedActor = projection.AcknowledgedBy
|
|
}
|
|
_ = s.DB.WithContext(ctx).Create(&audit).Error
|
|
}
|
|
func validAlertID(value string) *string {
|
|
if _, err := uuid.Parse(value); err != nil {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
func equalOptional(value *string, other string) bool {
|
|
if value == nil {
|
|
return other == ""
|
|
}
|
|
return *value == other
|
|
}
|