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 }