Files
terdut-server/internal/models/incident.go
T
Niklas Ye 9da913080f Stop 500ing when a service account acts on an incident
Every incident-mutation handler read userFromContext(ctx) and wrote the
result's .ID into acknowledged_by/incident_events.user_id without checking
the ok bool. For a team-scoped service-account caller this returned a
zero-value user id, which violated the users(id) FK and 500'd on
acknowledge, unacknowledge, resolve, snooze, unsnooze and create-note.
handleDeleteNote didn't crash but silently matched zero rows instead
(WHERE user_id = 0), so a service account could never delete its own note.

Add acknowledged_by_service_account_id (incidents) and service_account_id
(incident_events) as nullable FKs to service_accounts(id), parallel to and
mutually exclusive with the existing human columns (migration 015, with a
CHECK enforcing the exclusion). Route every one of the six handlers plus
delete-note through a new callerActorIDs() helper that branches on
Caller.AsHuman()/ServiceAccountID() instead of assuming a human, and thread
a serviceAccountID parameter through logEvent and the new
acknowledgeIncidentAs (acknowledgeIncident itself is untouched: its only
other caller, the push-notification Acknowledge button, is always human).
Render the new actor distinctly from both a human and "the server acted"
in the web UI's incident timeline and facts card.

handleIncidentAssign, handleIncidentArchive and handleIncidentUnarchive are
deliberately not touched here — they track no actor at all today, for
anyone, which is a separate pre-existing gap (follow-up issue to come).

Fixes #25
2026-10-07 21:09:31 +02:00

111 lines
5.1 KiB
Go

package models
import "time"
// Incident is the human work item: the thing that gets acknowledged, assigned,
// snoozed, discussed and resolved. Alerts are the machine-owned signal records
// underneath it — many alerts map to one incident, correlated by the groupKey
// Alertmanager already computed from the operator's group_by configuration.
//
// Nothing here is ever written by the Alertmanager webhook except Status, which
// the webhook and the sweeper may flip to "resolved" once every member alert has
// stopped firing.
type Incident struct {
// EscalationLevel is which rung of its team's ladder this incident is on,
// 0 for none — either the team has no ladder, or somebody has answered.
// EscalationDueAt is when the current level runs out, so a client can say
// how long is left rather than only what already happened.
EscalationLevel int64 `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
// TeamID is the team that owns this incident, fixed when it opens: an
// incident never moves between teams. TeamName rides along so the combined
// queue can badge each row without a second request.
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
ID int64 `json:"id"`
GroupKey string `json:"group_key"`
Title string `json:"title"`
GroupLabels map[string]string `json:"group_labels"`
// Status is "triggered", "acknowledged" or "resolved".
Status string `json:"status"`
// Severity is the highest `severity` label across the alerts that were
// firing when it was last recomputed. It is deliberately not cleared when an
// incident resolves — a resolved incident should still say how bad it was.
Severity *string `json:"severity,omitempty"`
TriggeredAt time.Time `json:"triggered_at"`
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// AcknowledgedByServiceAccountID/Name are the service-account-shaped
// parallel to AcknowledgedByID/User above: mutually exclusive with it,
// populated when a service account (not a human) acknowledged this
// incident. See migration 015 and terdut-server#25.
AcknowledgedByServiceAccountID *int64 `json:"acknowledged_by_service_account_id,omitempty"`
AcknowledgedByServiceAccountName *string `json:"acknowledged_by_service_account,omitempty"`
AssignedToID *int64 `json:"assigned_to_id,omitempty"`
AssignedToUser *string `json:"assigned_to,omitempty"`
// SnoozedUntil hides the incident from the default queue without closing it.
// A timestamp in the past reads as "not snoozed"; nothing sweeps it.
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolutionSource is "alerts" when every member alert stopped firing, or
// "manual" when a human closed it. Manual resolution is terminal: a later
// occurrence opens a new incident rather than reopening this one.
ResolutionSource *string `json:"resolution_source,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Alerts is populated by GET /api/incidents/{id} only.
Alerts []Alert `json:"alerts,omitempty"`
}
// IncidentEvent is one entry in an incident's timeline. The table is append-only
// and is the only history this server keeps — alert rows are mutated in place.
//
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. UserID and
// ServiceAccountID are mutually exclusive; both nil means the server acted
// rather than any caller.
type IncidentEvent struct {
ID int64 `json:"id"`
IncidentID int64 `json:"incident_id"`
Type string `json:"type"`
UserID *int64 `json:"user_id,omitempty"`
Username *string `json:"username,omitempty"`
// ServiceAccountID/Name are the service-account-shaped parallel to
// UserID/Username above: mutually exclusive with it, populated when a
// service account (not a human, and not nil-meaning-the-server-acted)
// performed this event. Named Name, not Username — a ServiceAccount has
// a Name field, not a Username. See migration 015 and terdut-server#25.
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
ServiceAccountName *string `json:"service_account_name,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"`
Detail *string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SimilarIncident is an earlier, resolved incident with the same signature as
// the one being looked at. ResolutionNotes are the "what fixed it" notes;
// NoteCount counts the plain working notes, which live on the timeline.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}