From 95dfe5efa31e68be4a9769745f7f0468313d9bd2 Mon Sep 17 00:00:00 2001
From: Aloento <11802769+Aloento@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:00:57 +0200
Subject: [PATCH 1/5] Normalize event statuses at the API layer and move
description out of updates
Map legacy status values (analyzing, in progress, scheduled, SYSTEM) to
their canonical forms in all API event outputs. changed and impact
changed rows are not status changes: they are replaced by the previous
normalized status in the updates sequence so the history does not show
a spurious jump.
description rows are no longer emitted in updates; the latest one is
returned as the event description field instead. This is an intentional
contract change that moves the frontend description workaround into the
backend. The database layer is untouched.
---
internal/api/v2/v2.go | 55 ++++++++++++--
internal/api/v2/v2_visibility_test.go | 100 +++++++++++++++++++++++++-
2 files changed, 146 insertions(+), 9 deletions(-)
diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go
index 68f205d..899244d 100644
--- a/internal/api/v2/v2.go
+++ b/internal/api/v2/v2.go
@@ -330,8 +330,12 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
components[i] = int(comp.ID)
}
+ updates, latestDescription := mapEventUpdates(inc.Statuses, isAuth, inc.EndDate, inc.Type)
+
var description string
- if inc.Description != nil {
+ if latestDescription != "" {
+ description = latestDescription
+ } else if inc.Description != nil {
description = *inc.Description
}
@@ -343,8 +347,8 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
StartDate: *inc.StartDate,
EndDate: inc.EndDate,
System: &inc.System,
- Updates: mapEventUpdates(inc.Statuses, isAuth),
- Status: inc.Status,
+ Updates: updates,
+ Status: normalizeStatus(inc.Status, inc.EndDate, inc.Type),
Type: inc.Type,
}
@@ -1848,23 +1852,62 @@ func PatchEventUpdateTextHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerF
}
}
-func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool) []EventUpdateData {
+// mapEventUpdates maps stored status rows to the API updates array. Rows with
+// status "description" are not status entries: they are dropped from the array
+// and the latest one is returned as the event description.
+func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool, endDate *time.Time, eventType string) ([]EventUpdateData, string) {
updates := make([]EventUpdateData, 0, len(statuses))
idx := 0
+ var latestDescription string
+ var lastStatus event.Status
for _, s := range statuses {
+ if s.Status == event.Status("description") {
+ latestDescription = s.Text
+ continue
+ }
if !isAuth && isInternalStatus(s.Status) {
continue
}
+ status := normalizeStatus(s.Status, endDate, eventType)
+ if status == event.IncidentChanged || status == event.IncidentImpactChanged {
+ status = lastStatus
+ }
+ lastStatus = status
updates = append(updates, EventUpdateData{
ID: idx,
- Status: s.Status,
+ Status: status,
Text: s.Text,
Timestamp: s.Timestamp,
})
idx++
}
- return updates
+ return updates, latestDescription
+}
+
+// normalizeStatus maps non-canonical status values stored in the database to
+// their canonical form. "changed" and "impact changed" are not status changes;
+// they are passed through and replaced by the previous normalized status in
+// the updates sequence so the history does not show a spurious jump.
+func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) event.Status {
+ switch raw {
+ case "analyzing":
+ return event.IncidentAnalysing
+ case "in progress":
+ return event.MaintenanceInProgress
+ case "scheduled":
+ return event.MaintenancePlanned
+ case event.OutDatedSystem:
+ if endDate == nil {
+ return raw
+ }
+ if eventType == event.TypeIncident {
+ return event.IncidentResolved
+ }
+ return event.MaintenanceCompleted
+ default:
+ return raw
+ }
}
// isInternalStatus returns true for statuses that should not be exposed to public (non-authenticated) users.
diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go
index d029b88..7c4d90b 100644
--- a/internal/api/v2/v2_visibility_test.go
+++ b/internal/api/v2/v2_visibility_test.go
@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stackmon/otc-status-dashboard/internal/db"
+ "github.com/stackmon/otc-status-dashboard/internal/event"
)
func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) {
@@ -20,27 +21,120 @@ func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) {
}
t.Run("authenticated sees all statuses", func(t *testing.T) {
- updates := mapEventUpdates(statuses, true)
+ updates, _ := mapEventUpdates(statuses, true, nil, event.TypeMaintenance)
assert.Len(t, updates, 4)
assert.Equal(t, "pending_review", string(updates[0].Status))
assert.Equal(t, "reviewed", string(updates[1].Status))
})
t.Run("unauthenticated sees only public statuses", func(t *testing.T) {
- updates := mapEventUpdates(statuses, false)
+ updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
assert.Len(t, updates, 2)
assert.Equal(t, "planned", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
})
t.Run("IDs are sequential after filtering", func(t *testing.T) {
- updates := mapEventUpdates(statuses, false)
+ updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
for i, u := range updates {
assert.Equal(t, i, u.ID)
}
})
}
+func TestMapEventUpdates_DescriptionRows(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "planned", Text: "Planned", Timestamp: testTime},
+ {ID: 2, Status: "description", Text: "First description", Timestamp: testTime},
+ {ID: 3, Status: "in_progress", Text: "In progress", Timestamp: testTime},
+ {ID: 4, Status: "description", Text: "Latest description", Timestamp: testTime},
+ }
+
+ t.Run("description rows are removed and latest text is returned", func(t *testing.T) {
+ updates, description := mapEventUpdates(statuses, true, nil, event.TypeMaintenance)
+ assert.Len(t, updates, 2)
+ assert.Equal(t, "planned", string(updates[0].Status))
+ assert.Equal(t, "in_progress", string(updates[1].Status))
+ assert.Equal(t, "Latest description", description)
+ })
+
+ t.Run("IDs are sequential after description removal", func(t *testing.T) {
+ updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
+ for i, u := range updates {
+ assert.Equal(t, i, u.ID)
+ }
+ })
+
+ t.Run("no description rows returns empty description", func(t *testing.T) {
+ _, description := mapEventUpdates(statuses[:1], true, nil, event.TypeMaintenance)
+ assert.Equal(t, "", description)
+ })
+}
+
+func TestMapEventUpdates_NormalizesStatuses(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "analyzing", Text: "Analysing", Timestamp: testTime},
+ {ID: 2, Status: "in progress", Text: "In progress", Timestamp: testTime},
+ {ID: 3, Status: "scheduled", Text: "Scheduled", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, &endDate, event.TypeMaintenance)
+ assert.Equal(t, "analysing", string(updates[0].Status))
+ assert.Equal(t, "in_progress", string(updates[1].Status))
+ assert.Equal(t, "planned", string(updates[2].Status))
+}
+
+func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "analysing", Text: "Analysing", Timestamp: testTime},
+ {ID: 2, Status: "impact changed", Text: "Impact changed", Timestamp: testTime},
+ {ID: 3, Status: "changed", Text: "Changed", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, nil, event.TypeIncident)
+ assert.Len(t, updates, 3)
+ assert.Equal(t, "analysing", string(updates[0].Status))
+ assert.Equal(t, "analysing", string(updates[1].Status))
+ assert.Equal(t, "analysing", string(updates[2].Status))
+}
+
+func TestNormalizeStatus(t *testing.T) {
+ endDate := time.Now().UTC()
+
+ tests := []struct {
+ name string
+ raw event.Status
+ endDate *time.Time
+ eventType string
+ want event.Status
+ }{
+ {name: "analyzing to analysing", raw: "analyzing", want: event.IncidentAnalysing},
+ {name: "in progress to in_progress", raw: "in progress", want: event.MaintenanceInProgress},
+ {name: "scheduled to planned", raw: "scheduled", want: event.MaintenancePlanned},
+ {name: "SYSTEM incident with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeIncident, want: event.IncidentResolved},
+ {name: "SYSTEM maintenance with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeMaintenance, want: event.MaintenanceCompleted},
+ {name: "SYSTEM info with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeInformation, want: event.MaintenanceCompleted},
+ {name: "SYSTEM without end date passes through", raw: event.OutDatedSystem, want: event.OutDatedSystem},
+ {name: "changed passes through", raw: event.IncidentChanged, want: event.IncidentChanged},
+ {name: "impact changed passes through", raw: event.IncidentImpactChanged, want: event.IncidentImpactChanged},
+ {name: "canonical status passes through", raw: event.IncidentResolved, want: event.IncidentResolved},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeStatus(tt.raw, tt.endDate, tt.eventType)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
func TestIsCancelledWithoutPublicStatus(t *testing.T) {
testTime := time.Now().UTC()
From 1b2369b76f6916224dfe3c46bf5574c518857276 Mon Sep 17 00:00:00 2001
From: Aloento <11802769+Aloento@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:07:27 +0200
Subject: [PATCH 2/5] Address review: seed collapsed statuses, keep lines and
tests lint-clean
Seed the collapsed status for a leading changed/impact changed row from the event status so it is never empty, capture description rows only after the visibility filter, and pick the completed status per event type. Update the outdated-incident acceptance assertions to the normalized resolved status.
---
internal/api/v2/v2.go | 34 +++++++++++++++++++--------
internal/api/v2/v2_visibility_test.go | 32 +++++++++++++++++--------
tests/v2_events_test.go | 2 +-
tests/v2_test.go | 2 +-
4 files changed, 48 insertions(+), 22 deletions(-)
diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go
index 899244d..a8f0dce 100644
--- a/internal/api/v2/v2.go
+++ b/internal/api/v2/v2.go
@@ -330,7 +330,7 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
components[i] = int(comp.ID)
}
- updates, latestDescription := mapEventUpdates(inc.Statuses, isAuth, inc.EndDate, inc.Type)
+ updates, latestDescription := mapEventUpdates(inc.Statuses, isAuth, inc.Status, inc.EndDate, inc.Type)
var description string
if latestDescription != "" {
@@ -1855,17 +1855,27 @@ func PatchEventUpdateTextHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerF
// mapEventUpdates maps stored status rows to the API updates array. Rows with
// status "description" are not status entries: they are dropped from the array
// and the latest one is returned as the event description.
-func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool, endDate *time.Time, eventType string) ([]EventUpdateData, string) {
+// statusDescription marks an update row that carries a description change rather
+// than a status change; the API folds it into the event description field.
+const statusDescription event.Status = "description"
+
+func mapEventUpdates(
+ statuses []db.IncidentStatus, isAuth bool, eventStatus event.Status,
+ endDate *time.Time, eventType string,
+) ([]EventUpdateData, string) {
updates := make([]EventUpdateData, 0, len(statuses))
idx := 0
var latestDescription string
- var lastStatus event.Status
+ // "changed" and "impact changed" are not status changes: they collapse to the
+ // previous normalized status. Seeding that with the event status keeps a
+ // leading such row from collapsing to an empty value.
+ lastStatus := normalizeStatus(eventStatus, endDate, eventType)
for _, s := range statuses {
- if s.Status == event.Status("description") {
- latestDescription = s.Text
+ if !isAuth && isInternalStatus(s.Status) {
continue
}
- if !isAuth && isInternalStatus(s.Status) {
+ if s.Status == statusDescription {
+ latestDescription = s.Text
continue
}
status := normalizeStatus(s.Status, endDate, eventType)
@@ -1887,8 +1897,8 @@ func mapEventUpdates(statuses []db.IncidentStatus, isAuth bool, endDate *time.Ti
// normalizeStatus maps non-canonical status values stored in the database to
// their canonical form. "changed" and "impact changed" are not status changes;
-// they are passed through and replaced by the previous normalized status in
-// the updates sequence so the history does not show a spurious jump.
+// they are passed through and collapsed to the previous status in the updates
+// sequence so the history shows no spurious jump.
func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) event.Status {
switch raw {
case "analyzing":
@@ -1901,10 +1911,14 @@ func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) eve
if endDate == nil {
return raw
}
- if eventType == event.TypeIncident {
+ switch eventType {
+ case event.TypeIncident:
return event.IncidentResolved
+ case event.TypeInformation:
+ return event.InfoCompleted
+ default:
+ return event.MaintenanceCompleted
}
- return event.MaintenanceCompleted
default:
return raw
}
diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go
index 7c4d90b..d9eaf72 100644
--- a/internal/api/v2/v2_visibility_test.go
+++ b/internal/api/v2/v2_visibility_test.go
@@ -21,21 +21,21 @@ func TestMapEventUpdates_FiltersInternalStatuses(t *testing.T) {
}
t.Run("authenticated sees all statuses", func(t *testing.T) {
- updates, _ := mapEventUpdates(statuses, true, nil, event.TypeMaintenance)
+ updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance)
assert.Len(t, updates, 4)
assert.Equal(t, "pending_review", string(updates[0].Status))
assert.Equal(t, "reviewed", string(updates[1].Status))
})
t.Run("unauthenticated sees only public statuses", func(t *testing.T) {
- updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
assert.Len(t, updates, 2)
assert.Equal(t, "planned", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
})
t.Run("IDs are sequential after filtering", func(t *testing.T) {
- updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
for i, u := range updates {
assert.Equal(t, i, u.ID)
}
@@ -53,7 +53,7 @@ func TestMapEventUpdates_DescriptionRows(t *testing.T) {
}
t.Run("description rows are removed and latest text is returned", func(t *testing.T) {
- updates, description := mapEventUpdates(statuses, true, nil, event.TypeMaintenance)
+ updates, description := mapEventUpdates(statuses, true, event.MaintenancePlanned, nil, event.TypeMaintenance)
assert.Len(t, updates, 2)
assert.Equal(t, "planned", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
@@ -61,15 +61,15 @@ func TestMapEventUpdates_DescriptionRows(t *testing.T) {
})
t.Run("IDs are sequential after description removal", func(t *testing.T) {
- updates, _ := mapEventUpdates(statuses, false, nil, event.TypeMaintenance)
+ updates, _ := mapEventUpdates(statuses, false, event.MaintenancePlanned, nil, event.TypeMaintenance)
for i, u := range updates {
assert.Equal(t, i, u.ID)
}
})
t.Run("no description rows returns empty description", func(t *testing.T) {
- _, description := mapEventUpdates(statuses[:1], true, nil, event.TypeMaintenance)
- assert.Equal(t, "", description)
+ _, description := mapEventUpdates(statuses[:1], true, event.MaintenancePlanned, nil, event.TypeMaintenance)
+ assert.Empty(t, description)
})
}
@@ -83,7 +83,7 @@ func TestMapEventUpdates_NormalizesStatuses(t *testing.T) {
{ID: 3, Status: "scheduled", Text: "Scheduled", Timestamp: testTime},
}
- updates, _ := mapEventUpdates(statuses, true, &endDate, event.TypeMaintenance)
+ updates, _ := mapEventUpdates(statuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance)
assert.Equal(t, "analysing", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
assert.Equal(t, "planned", string(updates[2].Status))
@@ -98,13 +98,25 @@ func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) {
{ID: 3, Status: "changed", Text: "Changed", Timestamp: testTime},
}
- updates, _ := mapEventUpdates(statuses, true, nil, event.TypeIncident)
+ updates, _ := mapEventUpdates(statuses, true, event.IncidentAnalysing, nil, event.TypeIncident)
assert.Len(t, updates, 3)
assert.Equal(t, "analysing", string(updates[0].Status))
assert.Equal(t, "analysing", string(updates[1].Status))
assert.Equal(t, "analysing", string(updates[2].Status))
}
+func TestMapEventUpdates_LeadingChangedUsesEventStatus(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {ID: 1, Status: "changed", Text: "Changed", Timestamp: testTime},
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, event.IncidentResolved, nil, event.TypeIncident)
+ assert.Len(t, updates, 1)
+ assert.Equal(t, event.IncidentResolved, updates[0].Status)
+}
+
func TestNormalizeStatus(t *testing.T) {
endDate := time.Now().UTC()
@@ -120,7 +132,7 @@ func TestNormalizeStatus(t *testing.T) {
{name: "scheduled to planned", raw: "scheduled", want: event.MaintenancePlanned},
{name: "SYSTEM incident with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeIncident, want: event.IncidentResolved},
{name: "SYSTEM maintenance with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeMaintenance, want: event.MaintenanceCompleted},
- {name: "SYSTEM info with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeInformation, want: event.MaintenanceCompleted},
+ {name: "SYSTEM info with end date", raw: event.OutDatedSystem, endDate: &endDate, eventType: event.TypeInformation, want: event.InfoCompleted},
{name: "SYSTEM without end date passes through", raw: event.OutDatedSystem, want: event.OutDatedSystem},
{name: "changed passes through", raw: event.IncidentChanged, want: event.IncidentChanged},
{name: "impact changed passes through", raw: event.IncidentImpactChanged, want: event.IncidentImpactChanged},
diff --git a/tests/v2_events_test.go b/tests/v2_events_test.go
index 59c63cb..e6baa62 100644
--- a/tests/v2_events_test.go
+++ b/tests/v2_events_test.go
@@ -223,7 +223,7 @@ func TestV2PostEventsHandler(t *testing.T) {
assert.Len(t, oldIncident.Updates, 3)
t.Logf("STATUS updates: %v", oldIncident.Updates)
assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status)
- assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status)
+ assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status)
assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status)
assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text)
assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text)
diff --git a/tests/v2_test.go b/tests/v2_test.go
index 14b4488..c96fd56 100644
--- a/tests/v2_test.go
+++ b/tests/v2_test.go
@@ -379,7 +379,7 @@ func TestV2PostIncidentsHandler(t *testing.T) {
assert.Len(t, oldIncident.Updates, 3)
t.Logf("STATUS updates: %v", oldIncident.Updates)
assert.Equal(t, event.IncidentDetected, oldIncident.Updates[0].Status)
- assert.Equal(t, event.OutDatedSystem, oldIncident.Updates[1].Status)
+ assert.Equal(t, event.IncidentResolved, oldIncident.Updates[1].Status)
assert.Equal(t, event.IncidentResolved, oldIncident.Updates[2].Status)
assert.Equal(t, "The incident is detected.", oldIncident.Updates[0].Text)
assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-DE, cce) moved to Test incident creation for api V2 for components: 1, 2. Test should close previous and move components to the new.", result.Result[0].IncidentID), oldIncident.Updates[1].Text)
From db941bbf172765c655654a308a1e896dfbfb2543 Mon Sep 17 00:00:00 2001
From: Aloento <11802769+Aloento@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:12:17 +0200
Subject: [PATCH 3/5] Collapse annotation statuses at the event level too
changed/impact changed annotate a change rather than denote a state, so the event-level status now collapses to the previous status from the update history, matching the updates sequence. OutDatedSystem without an end date stays verbatim: it has no canonical terminal status and consumers map it to the previous status.
---
internal/api/v2/v2.go | 31 +++++++++--
internal/api/v2/v2_visibility_test.go | 75 +++++++++++++++++++++++++++
2 files changed, 103 insertions(+), 3 deletions(-)
diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go
index a8f0dce..e6bee4c 100644
--- a/internal/api/v2/v2.go
+++ b/internal/api/v2/v2.go
@@ -348,7 +348,7 @@ func toAPIEvent(inc *db.Incident, isAuth bool) *Incident {
EndDate: inc.EndDate,
System: &inc.System,
Updates: updates,
- Status: normalizeStatus(inc.Status, inc.EndDate, inc.Type),
+ Status: eventStatus(inc),
Type: inc.Type,
}
@@ -1895,10 +1895,35 @@ func mapEventUpdates(
return updates, latestDescription
}
+// eventStatus normalizes the stored event status. "changed" and "impact changed"
+// annotate a change rather than denote a state, so they are collapsed to the
+// previous status from the update history, matching the updates sequence.
+func eventStatus(inc *db.Incident) event.Status {
+ status := normalizeStatus(inc.Status, inc.EndDate, inc.Type)
+ if status != event.IncidentChanged && status != event.IncidentImpactChanged {
+ return status
+ }
+
+ prev := status
+ for _, s := range inc.Statuses {
+ if s.Status == statusDescription {
+ continue
+ }
+ row := normalizeStatus(s.Status, inc.EndDate, inc.Type)
+ if row == event.IncidentChanged || row == event.IncidentImpactChanged {
+ row = prev
+ }
+ prev = row
+ }
+
+ return prev
+}
+
// normalizeStatus maps non-canonical status values stored in the database to
// their canonical form. "changed" and "impact changed" are not status changes;
-// they are passed through and collapsed to the previous status in the updates
-// sequence so the history shows no spurious jump.
+// they are collapsed to the previous status by eventStatus and mapEventUpdates.
+// OutDatedSystem without an end date is kept verbatim: it has no canonical
+// terminal status, and consumers map it to the previous status.
func normalizeStatus(raw event.Status, endDate *time.Time, eventType string) event.Status {
switch raw {
case "analyzing":
diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go
index d9eaf72..62eb4a3 100644
--- a/internal/api/v2/v2_visibility_test.go
+++ b/internal/api/v2/v2_visibility_test.go
@@ -87,6 +87,81 @@ func TestMapEventUpdates_NormalizesStatuses(t *testing.T) {
assert.Equal(t, "analysing", string(updates[0].Status))
assert.Equal(t, "in_progress", string(updates[1].Status))
assert.Equal(t, "planned", string(updates[2].Status))
+
+ systemStatuses := []db.IncidentStatus{
+ {ID: 1, Status: event.OutDatedSystem, Text: "Moved", Timestamp: testTime},
+ }
+ systemUpdates, _ := mapEventUpdates(systemStatuses, true, event.MaintenancePlanned, &endDate, event.TypeMaintenance)
+ assert.Equal(t, event.MaintenanceCompleted, systemUpdates[0].Status)
+}
+
+func TestEventStatus(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+ past := testTime.Add(-time.Hour)
+
+ t.Run("changed on a closed incident collapses to the previous status", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentChanged,
+ EndDate: &endDate,
+ Statuses: []db.IncidentStatus{
+ {Status: event.IncidentDetected, Timestamp: past},
+ {Status: event.IncidentResolved, Timestamp: past},
+ },
+ }
+ assert.Equal(t, event.IncidentResolved, eventStatus(inc))
+ })
+
+ t.Run("impact changed on an open incident collapses to the previous status", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentImpactChanged,
+ Statuses: []db.IncidentStatus{
+ {Status: event.IncidentDetected, Timestamp: past},
+ {Status: event.IncidentAnalysing, Timestamp: past},
+ },
+ }
+ assert.Equal(t, event.IncidentAnalysing, eventStatus(inc))
+ })
+
+ t.Run("a plain status is returned normalized", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: "analyzing",
+ Statuses: []db.IncidentStatus{{Status: "analyzing", Timestamp: past}},
+ }
+ assert.Equal(t, event.IncidentAnalysing, eventStatus(inc))
+ })
+
+ t.Run("SYSTEM without an end date is kept verbatim", func(t *testing.T) {
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.OutDatedSystem,
+ Statuses: []db.IncidentStatus{{Status: event.OutDatedSystem, Timestamp: past}},
+ }
+ assert.Equal(t, event.OutDatedSystem, eventStatus(inc))
+ })
+}
+
+func TestEventStatus_MatchesLastUpdate(t *testing.T) {
+ testTime := time.Now().UTC()
+ endDate := testTime.Add(time.Hour)
+
+ statuses := []db.IncidentStatus{
+ {Status: event.IncidentDetected, Text: "detected", Timestamp: testTime},
+ {Status: event.IncidentResolved, Text: "resolved", Timestamp: testTime},
+ {Status: event.IncidentChanged, Text: "changed", Timestamp: testTime},
+ }
+ inc := &db.Incident{
+ Type: event.TypeIncident,
+ Status: event.IncidentChanged,
+ EndDate: &endDate,
+ Statuses: statuses,
+ }
+
+ updates, _ := mapEventUpdates(statuses, true, inc.Status, inc.EndDate, inc.Type)
+ assert.Equal(t, eventStatus(inc), updates[len(updates)-1].Status)
}
func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) {
From 0fa7d225866fd9c629ed387a73a7468dd1c792aa Mon Sep 17 00:00:00 2001
From: Aloento <11802769+Aloento@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:15:35 +0200
Subject: [PATCH 4/5] Update closed-incident patch assertions to the normalized
status
Changing the dates of a closed incident now reports the collapsed resolved status instead of the changed annotation.
---
tests/v2_events_test.go | 2 +-
tests/v2_test.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/v2_events_test.go b/tests/v2_events_test.go
index e6baa62..ed14954 100644
--- a/tests/v2_events_test.go
+++ b/tests/v2_events_test.go
@@ -506,7 +506,7 @@ func TestV2PatchEventHandler(t *testing.T) {
inc = internalPatch(incID, &pData)
assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate)
- assert.Equal(t, event.IncidentChanged, inc.Status)
+ assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status")
require.NotNil(t, inc.EndDate)
assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond))
assert.Nil(t, inc.Version, "Version must not be exposed for non-maintenance events")
diff --git a/tests/v2_test.go b/tests/v2_test.go
index c96fd56..d94a3c6 100644
--- a/tests/v2_test.go
+++ b/tests/v2_test.go
@@ -664,7 +664,7 @@ func TestV2PatchIncidentHandler(t *testing.T) {
inc = internalPatch(incID, &pData)
assert.Equal(t, startDate.Truncate(time.Microsecond), inc.StartDate)
- assert.Equal(t, event.IncidentChanged, inc.Status)
+ assert.Equal(t, event.IncidentResolved, inc.Status, "changing dates on a closed incident keeps the resolved status")
require.NotNil(t, inc.EndDate)
assert.Equal(t, endDate.Truncate(time.Microsecond), inc.EndDate.Truncate(time.Microsecond))
From 50a59251e683348d0c4af0fc79fcf488ff182169 Mon Sep 17 00:00:00 2001
From: Aloento <11802769+Aloento@users.noreply.github.com>
Date: Sun, 4 Oct 2026 22:18:28 +0200
Subject: [PATCH 5/5] Pin the view-independence of the event status
Document that eventStatus walks the full history while updates[] follows the caller's visibility, and cover the difference with a test.
---
internal/api/v2/v2.go | 4 +++-
internal/api/v2/v2_visibility_test.go | 17 +++++++++++++++++
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go
index e6bee4c..9ace5b4 100644
--- a/internal/api/v2/v2.go
+++ b/internal/api/v2/v2.go
@@ -1897,7 +1897,9 @@ func mapEventUpdates(
// eventStatus normalizes the stored event status. "changed" and "impact changed"
// annotate a change rather than denote a state, so they are collapsed to the
-// previous status from the update history, matching the updates sequence.
+// previous status from the update history, matching the updates sequence. It
+// walks the full history, so unlike updates[] the result does not depend on the
+// caller's visibility.
func eventStatus(inc *db.Incident) event.Status {
status := normalizeStatus(inc.Status, inc.EndDate, inc.Type)
if status != event.IncidentChanged && status != event.IncidentImpactChanged {
diff --git a/internal/api/v2/v2_visibility_test.go b/internal/api/v2/v2_visibility_test.go
index 62eb4a3..67dd030 100644
--- a/internal/api/v2/v2_visibility_test.go
+++ b/internal/api/v2/v2_visibility_test.go
@@ -164,6 +164,23 @@ func TestEventStatus_MatchesLastUpdate(t *testing.T) {
assert.Equal(t, eventStatus(inc), updates[len(updates)-1].Status)
}
+func TestEventStatus_DoesNotDependOnVisibility(t *testing.T) {
+ testTime := time.Now().UTC()
+
+ statuses := []db.IncidentStatus{
+ {Status: event.MaintenancePlanned, Text: "planned", Timestamp: testTime},
+ {Status: event.MaintenanceReviewed, Text: "reviewed", Timestamp: testTime},
+ }
+ inc := &db.Incident{Type: event.TypeMaintenance, Status: event.MaintenanceReviewed, Statuses: statuses}
+
+ assert.Equal(t, event.MaintenanceReviewed, eventStatus(inc))
+
+ // The unauthenticated view filters the trailing internal row, so the last
+ // visible update can differ from the view-independent event status.
+ public, _ := mapEventUpdates(statuses, false, inc.Status, inc.EndDate, inc.Type)
+ assert.Equal(t, event.MaintenancePlanned, public[len(public)-1].Status)
+}
+
func TestMapEventUpdates_ChangedStatusesKeepPrevious(t *testing.T) {
testTime := time.Now().UTC()