hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s

Allowlisted, not operator-only, seeded for new households and added
once (add-only) to every existing enabled_events row. mail.event entries
in hu and en name the app from details.stack_name. Per-app cooldown on
both the operator and the household leg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:47:13 +02:00
parent 05ea21e918
commit d3b284863e
22 changed files with 563 additions and 5 deletions
+29
View File
@@ -1,3 +1,32 @@
## v0.120.0 — the household is TOLD when an update is undone or held (2026-09-23, `09` §3 decision 15)
Controller v0.264.0 sends two new events when a guarded app update does not go through. This hub
release lets them reach the household, in its language, one mail per app per outcome.
- **`app_update_undone`** (warning) — the update failed and the box put the previous version and its
data back by itself. **`app_update_held`** (error) — the update failed, the undo failed too (or the
update held for another reason), and the app is stopped until a restore.
- **Both in `allowedEventTypes`** (a push no longer 400s), **NOT operator-only**, and **in
`defaultSeedEvents`** so a new household has them on from day one.
- **`mail.event.*` in Hungarian AND English**, and they **name the app**: a new named register
`appNamedMailEvents` renders the entry with `details.stack_name` (`„docmost: a frissítés nem
sikerült, …"` / `"docmost: the update did not work; …"`). No stack_name → the box's own sentence;
neither → `?` in the app's place. Never a literal `%s`, never raw English. The box's sentence
(the undone line, or the hold sentence in the household's language) is the mail's message line.
- **One-time, ADD-ONLY migration** of every existing `customer_notifications.enabled_events` row
(`Store.SeedEventTypesOnce`, guard row `seed_app_update_events_v1` in `hub_settings`). It never
removes a type or reorders a list; a corrupt row is left as it is and named in the log; a later
opt-out sticks because it runs once. The startup log names every customer whose row changed.
- **Cooldown per app for these two types, on both legs.** Operator: joined R-389's
`perAppCooldownEvents` (the fence test widened on purpose, with its reason). Household: a NEW
sibling register `perAppCustomerCooldownEvents` — the customer key was `customer:type` for every
type, so two apps undone on one night reached the household as ONE mail. `app_start_failed`'s
household grain is deliberately unchanged (pinned).
- A skipped customer mail now logs its cooldown key (it was silent).
- Tests: `TestAppUpdateSeed_*` (store, reopened like a real upgrade), `TestAppUpdateEvents_*`
(dispatcher), `TestAppUpdateOutcomesReachTheHousehold` (api), `TestDefaultSeedIncludesTheUpdateOutcomes`
(claim), 8 new mail goldens. Five red-proofs, each seen failing (REPORT).
## v0.119.0 — English households get English words for their codes (2026-09-21, R-597)
The 2026-09-20 English drill received a setup code of **three Hungarian words with accents**
@@ -47,3 +47,23 @@ func TestRestoreInterruptedReachesTheHouseholdInHungarian(t *testing.T) {
t.Fatalf("negative control matched — the fragment search is broken")
}
}
// v0.120.0 — the two update outcomes reach the household, in its language, never as raw English.
// RED-PROOF (REPORT.md): drop app_update_undone from allowedEventTypes → "must be in allowedEventTypes".
func TestAppUpdateOutcomesReachTheHousehold(t *testing.T) {
for _, et := range []string{"app_update_undone", "app_update_held"} {
if !allowedEventTypes[et] {
t.Fatalf("%s must be in allowedEventTypes — the controller's push would 400", et)
}
if notify.IsOperatorOnly(et) {
t.Fatalf("%s must reach the household", et)
}
const raw = "RAW-ENGLISH-SENTINEL"
for _, lang := range []string{"hu", "en"} {
subject, _ := notify.FormatCustomerEmail(lang, "c1", et, "warning", raw, "", `{"stack_name":"docmost"}`)
if strings.Contains(subject, raw) || !strings.Contains(subject, "docmost:") {
t.Fatalf("%s/%s subject must be the entry naming the app, got %q", et, lang, subject)
}
}
}
}
+7
View File
@@ -2013,6 +2013,13 @@ var allowedEventTypes = map[string]bool{
// `backup_run_digest_event_test.go` pins the pair.
"backup_run_failures": true,
// v0.120.0 (`09` §3 decision 15): an app update the box UNDID by itself (warning) and one that ended
// HELD — the undo failed too, or the update held for any reason (error). BOTH reach the household
// (one mail per app per outcome — per-app cooldown, notify.perAppCustomerCooldownEvents) and the
// operator; both carry a Hungarian AND English `mail.event.*` entry, never the raw-English fallback.
"app_update_undone": true,
"app_update_held": true,
// Controller-pushed events
"controller_started": true,
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
+5
View File
@@ -233,6 +233,11 @@ var defaultSeedEvents = []string{
"host_disk_critical",
"storage_fill_critical",
"offbox_repo_orphaned",
// v0.120.0 (`09` §3 decision 15): the household is told ONCE when an update is undone, and when
// it (or its undo) failed and the app is held. Existing households get them by the one-time,
// add-only store.SeedEventTypesOnce.
"app_update_undone",
"app_update_held",
}
// MarkClaimed records a controller-reported successful claim and sends the one-time confirmation
+14
View File
@@ -76,3 +76,17 @@ func TestMarkClaimed_EmptyEmail_NoRow_ClaimSucceeds(t *testing.T) {
t.Fatalf("idempotent re-claim: %v", err)
}
}
// v0.120.0 — a NEW household hears about an undone or held update from day one.
// RED-PROOF (REPORT.md): drop app_update_undone from defaultSeedEvents → this fails naming it.
func TestDefaultSeedIncludesTheUpdateOutcomes(t *testing.T) {
have := map[string]bool{}
for _, e := range defaultSeedEvents {
have[e] = true
}
for _, et := range []string{"app_update_undone", "app_update_held"} {
if !have[et] {
t.Fatalf("defaultSeedEvents must include %s", et)
}
}
}
+3 -1
View File
@@ -79,5 +79,7 @@
"bind.invalid.body": "The link is valid for 7 days. If it has expired, ask support for a new one, or your operator can do the linking.",
"bind.htmllang": "en",
"mail.test.subject": "[Felhom] Test notification",
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring"
"mail.test.body": "Dear Customer,\n\nThis is a test notification from the Felhom monitoring system.\nNotifications are working correctly.\n\nBest regards,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: the update did not work; the app runs on its previous version",
"mail.event.app_update_held": "%s: the app is stopped and needs a restore"
}
+3 -1
View File
@@ -79,5 +79,7 @@
"bind.invalid.body": "A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.",
"bind.htmllang": "hu",
"mail.test.subject": "[Felhom] Teszt értesítés",
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring"
"mail.test.body": "Kedves Ügyfél!\n\nEz egy teszt értesítés a Felhom monitoring rendszerből.\nAz értesítések megfelelően működnek.\n\nÜdvözlettel,\nFelhom.eu monitoring",
"mail.event.app_update_undone": "%s: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut",
"mail.event.app_update_held": "%s: az alkalmazás leállítva, visszaállítás szükséges"
}
@@ -0,0 +1,67 @@
package notify
import (
"io"
"log"
"strings"
"testing"
)
// v0.120.0 — the household hears about an update ONCE per app per event (R-629: a storm is a
// defect), and two apps on one night are two mails.
//
// COMPANION RED-PROOF (REPORT.md): drop the perAppCustomerCooldownEvents suffix in processCustomer —
// the second app's mail is swallowed by the first app's cooldown and this fails on the count.
func TestAppUpdateEvents_OneMailPerAppPerEvent(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com",
[]string{"app_update_undone", "app_update_held"}, 6); err != nil {
t.Fatal(err)
}
var subjects []string
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, subject, _ string, _ map[string]string) error {
if to == "customer@example.com" {
subjects = append(subjects, subject)
}
return nil
}
fire := func(et, app string) {
d.ProcessEvent("c1", et, "warning", "A(z) "+app+" frissitese nem sikerult.",
`{"app":"`+app+`","stack_name":"`+app+`"}`, "controller")
}
fire("app_update_undone", "docmost")
fire("app_update_undone", "romm") // another app, same night → its own mail
fire("app_update_undone", "docmost") // the same app again → no second mail
fire("app_update_held", "docmost") // another EVENT for the same app → its own mail
if len(subjects) != 3 {
t.Fatalf("want 3 household mails (docmost undone, romm undone, docmost held), got %d: %q", len(subjects), subjects)
}
for i, app := range []string{"docmost", "romm", "docmost"} {
if !strings.Contains(subjects[i], app+":") {
t.Errorf("mail %d must name %s in the subject, got %q", i, app, subjects[i])
}
}
}
// The other types keep their customer-side grain: the new register is not a blanket change.
func TestAppUpdateEvents_OtherTypesKeepTheirCustomerGrain(t *testing.T) {
st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{"app_start_failed"}, 6); err != nil {
t.Fatal(err)
}
n := 0
d := NewDispatcher(st, "test-key", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
d.sendEmailFn = func(to, _, _ string, _ map[string]string) error {
if to == "customer@example.com" {
n++
}
return nil
}
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"a"}`, "controller")
d.ProcessEvent("c1", "app_start_failed", "error", "x", `{"stack_name":"b"}`, "controller")
if n != 1 {
t.Fatalf("app_start_failed's household cooldown stays per TYPE (unchanged by v0.120.0), got %d mails", n)
}
}
+22
View File
@@ -389,6 +389,18 @@ var perAppCooldownEvents = map[string]bool{
// per-app is the only grain available that does not lose alarms. Measured 2026-08-23: two apps
// four minutes apart produced one mail and one suppression.
"app_start_failed": true,
// v0.120.0: an update outcome is per app with no digest — two apps on one night are two alarms.
"app_update_undone": true,
"app_update_held": true,
}
// perAppCustomerCooldownEvents is the CUSTOMER-leg sibling of perAppCooldownEvents (v0.120.0). The
// household's cooldown was keyed `customer:type` for every type, so two apps undone on the same night
// would reach the household as ONE mail. Its own register, deliberately — putting app_start_failed's
// customer leg on a per-app key would change a type nobody asked to change.
var perAppCustomerCooldownEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
}
// cooldownStackSuffix returns ":"+stack_name when the event's details carry a non-empty `stack_name`
@@ -417,6 +429,12 @@ func cooldownStackSuffix(eventType, detailsJSON string) string {
if !perAppCooldownEvents[eventType] {
return ""
}
return cooldownStackSuffixFor(detailsJSON)
}
// cooldownStackSuffixFor is the payload half of cooldownStackSuffix, register-free — callers decide
// which register applies (operator: perAppCooldownEvents; customer: perAppCustomerCooldownEvents).
func cooldownStackSuffixFor(detailsJSON string) string {
if detailsJSON == "" || !strings.Contains(detailsJSON, "\"stack_name\"") {
return ""
}
@@ -677,9 +695,13 @@ func (d *Dispatcher) processCustomer(customerID, eventType, severity, message, m
cooldownDur := time.Duration(cooldownHours) * time.Hour
cooldownKey := customerID + ":" + eventType
if perAppCustomerCooldownEvents[eventType] {
cooldownKey += cooldownStackSuffixFor(detailsJSON)
}
d.mu.Lock()
if last, ok := d.custCooldowns[cooldownKey]; ok && time.Since(last) < cooldownDur {
d.mu.Unlock()
d.logger.Printf("[INFO] Customer mail skipped for %s/%s — cooldown (key %s)", customerID, eventType, cooldownKey)
return
}
d.custCooldowns[cooldownKey] = time.Now()
+18
View File
@@ -85,6 +85,24 @@ func customerMailCases(lang string) []mailCase {
return FormatCustomerEmail(lang, "demo-fixture", "app_deployed", "info", "", "", `{"app":"bentopdf"}`)
},
},
mailCase{
name: "customer_shape_app_update_undone_names_the_app",
comment: "v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line",
render: func() (string, string) {
return FormatCustomerEmail(lang, "demo-fixture", "app_update_undone", "warning",
"A(z) docmost frissitese nem sikerult.", "",
`{"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}`)
},
},
mailCase{
name: "customer_shape_app_update_held_names_the_app",
comment: "v0.120.0: the held entry names the app; the hold sentence is the line",
render: func() (string, string) {
return FormatCustomerEmail(lang, "demo-fixture", "app_update_held", "error",
"A frissites nem sikerult, es az automatikus visszaallitas sem.", "",
`{"app":"vikunja","stack_name":"vikunja","copy_tier":2}`)
},
},
mailCase{
name: "customer_shape_empty_details_object_is_omitted",
comment: "{} is not details",
@@ -61,15 +61,24 @@ func TestR389_StackSuffixIsAllowListedAndFailSoft(t *testing.T) {
}
}
// The register must stay narrow. A second entry is a deliberate act and should fail this until
// The register must stay narrow. A new entry is a deliberate act and should fail this until
// someone changes it on purpose, having read the fence.
//
// v0.120.0 widened it ON PURPOSE, by the brief "the undo reaches the fleet" (`09` §3 decision 15):
// an update outcome is one app's event, with no digest behind it — two apps undone on one night are
// two alarms. The backup family stays coarse, as the fence says.
func TestR389_TheAllowListHasExactlyOneMember(t *testing.T) {
if len(perAppCooldownEvents) != 1 || !perAppCooldownEvents["app_start_failed"] {
want := []string{"app_start_failed", "app_update_held", "app_update_undone"}
ok := len(perAppCooldownEvents) == len(want)
for _, w := range want {
ok = ok && perAppCooldownEvents[w]
}
if !ok {
var got []string
for k := range perAppCooldownEvents {
got = append(got, k)
}
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed]. Adding a member is the "+
t.Fatalf("perAppCooldownEvents = %v, want exactly [app_start_failed app_update_held app_update_undone]. Adding a member is the "+
"fenced act: the backup family's cooldown is coarse ON PURPOSE (R-97a, R-182) so one full "+
"disk sends one digest, not one mail per app. Read the fence before widening this.", got)
}
+35
View File
@@ -156,6 +156,20 @@ func FormatCustomerEmail(lang, customerID, eventType, severity, message, message
headline := ""
if b.Has(i18n.Default, "mail.event."+eventType) {
headline = b.Msg(lang, "mail.event."+eventType)
// v0.120.0: an entry that NAMES THE APP (`%s`) is rendered with the details' stack_name — the
// subject then reads "docmost: …" rather than a sentence that could be about any app. No
// stack_name → the box's own sentence; neither → "?" in the app's place. Never a literal "%s"
// in a household's inbox, never an empty subject.
if appNamedMailEvents[eventType] {
switch app := stackNameOf(detailsJSON); {
case app != "":
headline = b.Msgf(lang, "mail.event."+eventType, app)
case boxMessage != "":
headline = ""
default:
headline = b.Msgf(lang, "mail.event."+eventType, "?")
}
}
}
if headline == "" {
headline = boxMessage
@@ -363,3 +377,24 @@ func trimRepeatedUsage(reason, targetPath string) string {
}
return strings.TrimSpace(reason[:i])
}
// appNamedMailEvents are the types whose `mail.event.*` entry carries the app's name as `%s`
// (v0.120.0). A named register, like operatorOnlyEvents: an entry gains an argument only on purpose.
var appNamedMailEvents = map[string]bool{
"app_update_undone": true,
"app_update_held": true,
}
// stackNameOf reads `stack_name` from an event's details, "" when absent or unreadable.
func stackNameOf(detailsJSON string) string {
if detailsJSON == "" {
return ""
}
var d struct {
StackName string `json:"stack_name"`
}
if json.Unmarshal([]byte(detailsJSON), &d) != nil {
return ""
}
return d.StackName
}
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
?: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_held
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Warning: ?: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
?: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Error: vikunja: the app is stopped and needs a restore
---
Dear Customer,
Your Felhom system sent the following notification:
vikunja: the app is stopped and needs a restore
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Error
- Type: app_update_held
- Message: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Note: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Warning: docmost: the update did not work; the app runs on its previous version
---
Dear Customer,
Your Felhom system sent the following notification:
docmost: the update did not work; the app runs on its previous version
Details:
- Server: demo-fixture
- Time: 2026-01-15 10:30
- Level: Warning
- Type: app_update_undone
- Message: A(z) docmost frissitese nem sikerult.
- Note: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
If you have any questions, contact your operator.
Best regards,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_held
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,18 @@
SUBJECT: [Felhom] Figyelmeztetés: ?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
?: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the held entry names the app; the hold sentence is the line
SUBJECT: [Felhom] Hiba: vikunja: az alkalmazás leállítva, visszaállítás szükséges
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
vikunja: az alkalmazás leállítva, visszaállítás szükséges
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Hiba
- Típus: app_update_held
- Üzenet: A frissites nem sikerult, es az automatikus visszaallitas sem.
- Megjegyzés: {"app":"vikunja","stack_name":"vikunja","copy_tier":2}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
@@ -0,0 +1,21 @@
# v0.120.0: the entry names the app from details.stack_name; the box's sentence is the line
SUBJECT: [Felhom] Figyelmeztetés: docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
---
Kedves Ügyfél!
A Felhom rendszered a következő értesítést küldte:
docmost: a frissítés nem sikerült, az alkalmazás a korábbi változattal fut
Részletek:
- Szerver: demo-fixture
- Időpont: 2026-01-15 10:30
- Szint: Figyelmeztetés
- Típus: app_update_undone
- Üzenet: A(z) docmost frissitese nem sikerult.
- Megjegyzés: {"app":"docmost","stack_name":"docmost","from":{"docmost":"docmost:0.95.0"},"to":{"docmost":"docmost:0.96.0"}}
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"io"
"log"
"path/filepath"
"reflect"
"testing"
)
// v0.120.0 — every EXISTING household hears about an undone or held update (`09` §3 decision 15),
// by a ONE-TIME, ADD-ONLY migration. Run the real way: a DB written by an older hub (no guard row),
// reopened by this one.
//
// COMPANION RED-PROOF (REPORT.md): drop the SeedEventTypesOnce call from migrate() — c1 then keeps
// [backup_failed] and this test fails on the first row.
func TestAppUpdateSeed_ExistingHouseholdsGainBothTypesOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "hub.db")
s, err := New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
// An older hub's DB: no guard row, four households.
if _, err := s.db.Exec(`DELETE FROM hub_settings WHERE key = 'seed_app_update_events_v1'`); err != nil {
t.Fatal(err)
}
for id, ev := range map[string][]string{
"c1": {"backup_failed"},
"c2": {"app_update_undone"},
"c3": {"app_update_undone", "app_update_held", "disk_warning"},
} {
if err := s.SaveNotificationPrefs(id, id+"@example.com", ev, 6); err != nil {
t.Fatal(err)
}
}
if err := s.SaveNotificationPrefs("c4", "c4@example.com", nil, 6); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`UPDATE customer_notifications SET enabled_events = 'not json' WHERE customer_id = 'c4'`); err != nil {
t.Fatal(err)
}
s.Close()
s = reopen(t, path)
want := map[string][]string{
"c1": {"backup_failed", "app_update_undone", "app_update_held"},
"c2": {"app_update_undone", "app_update_held"},
"c3": {"app_update_undone", "app_update_held", "disk_warning"}, // untouched — order kept
}
for id, w := range want {
p, err := s.GetNotificationPrefs(id)
if err != nil || p == nil {
t.Fatalf("%s: %v", id, err)
}
if !reflect.DeepEqual(p.EnabledEvents, w) {
t.Errorf("%s: enabled_events = %v, want %v (add-only: nothing removed, nothing reordered)", id, p.EnabledEvents, w)
}
}
var raw string
if err := s.db.QueryRow(`SELECT enabled_events FROM customer_notifications WHERE customer_id='c4'`).Scan(&raw); err != nil || raw != "not json" {
t.Errorf("a corrupt row must be left exactly as it was, got %q (%v)", raw, err)
}
// A household that opts OUT afterwards stays out: the migration runs once.
if err := s.SaveNotificationPrefs("c1", "c1@example.com", []string{"backup_failed"}, 6); err != nil {
t.Fatal(err)
}
s.Close()
s = reopen(t, path)
if p, _ := s.GetNotificationPrefs("c1"); !reflect.DeepEqual(p.EnabledEvents, []string{"backup_failed"}) {
t.Fatalf("a later opt-out must stick — the seed must run ONCE; got %v", p.EnabledEvents)
}
}
// The direct call reports which rows changed — that list is what the startup log names.
func TestAppUpdateSeed_ReportsTheChangedRows(t *testing.T) {
s := newTestStore(t)
if err := s.SaveNotificationPrefs("a", "a@example.com", []string{"backup_failed"}, 6); err != nil {
t.Fatal(err)
}
if err := s.SaveNotificationPrefs("b", "b@example.com", []string{"x_undone", "x_held"}, 6); err != nil {
t.Fatal(err)
}
got, err := s.SeedEventTypesOnce("test_guard", []string{"x_undone", "x_held"})
if err != nil || !reflect.DeepEqual(got, []string{"a"}) {
t.Fatalf("changed = %v (%v), want [a]", got, err)
}
if again, err := s.SeedEventTypesOnce("test_guard", []string{"x_undone", "x_held"}); err != nil || again != nil {
t.Fatalf("a second run must do nothing, got %v (%v)", again, err)
}
}
func reopen(t *testing.T, path string) *Store {
t.Helper()
s, err := New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
+71
View File
@@ -793,9 +793,80 @@ func (s *Store) migrate() error {
}
}
// v0.120.0 (`09` §3 decision 15): every existing household hears about an undone or held update.
// ONE-TIME and ADD-ONLY — see SeedEventTypesOnce.
if changed, err := s.SeedEventTypesOnce("seed_app_update_events_v1", []string{"app_update_undone", "app_update_held"}); err != nil {
return fmt.Errorf("app-update event seed: %w", err)
} else if changed != nil && s.logger != nil {
s.logger.Printf("[INFO] [store] app-update event types added to %d household(s)' notification prefs (one-time, add-only): %v", len(changed), changed)
}
return nil
}
// SeedEventTypesOnce adds event types to EVERY existing household's enabled_events, once (guarded by a
// hub_settings row named guardKey), and returns the customers whose row changed (nil when the guard was
// already set).
//
// ADD-ONLY, and that is the whole safety property: it never removes a type and never touches a row that
// already lists them. A household could not have switched off a type that did not exist, so adding it
// respects their choice; a LATER opt-out sticks because the guard makes this run once. A corrupt
// enabled_events value is left alone and named in the log (never "repaired" into a guess).
func (s *Store) SeedEventTypesOnce(guardKey string, types []string) ([]string, error) {
var done string
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = ?`, guardKey).Scan(&done); err == nil && done != "" {
return nil, nil
}
rows, err := s.db.Query(`SELECT customer_id, enabled_events FROM customer_notifications`)
if err != nil {
return nil, err
}
type row struct{ id, ev string }
var all []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.ev); err != nil {
rows.Close()
return nil, err
}
all = append(all, r)
}
rows.Close()
changed := []string{}
for _, r := range all {
var list []string
if err := json.Unmarshal([]byte(r.ev), &list); err != nil {
if s.logger != nil {
s.logger.Printf("[WARN] [store] event seed %s: %s has corrupt enabled_events — left as it is: %v", guardKey, r.id, err)
}
continue
}
have := map[string]bool{}
for _, e := range list {
have[e] = true
}
added := false
for _, t := range types {
if !have[t] {
list = append(list, t)
added = true
}
}
if !added {
continue
}
b, _ := json.Marshal(list)
if _, err := s.db.Exec(`UPDATE customer_notifications SET enabled_events = ?, updated_at = datetime('now') WHERE customer_id = ?`, string(b), r.id); err != nil {
return nil, err
}
changed = append(changed, r.id)
}
if _, err := s.db.Exec(`INSERT INTO hub_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, guardKey, time.Now().UTC().Format(time.RFC3339)); err != nil {
return nil, err
}
return changed, nil
}
// NotificationPrefs holds per-customer notification preferences.
type NotificationPrefs struct {
CustomerID string