diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index f2afc635..98af6183 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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** diff --git a/hub/internal/api/chaosnight_events_test.go b/hub/internal/api/chaosnight_events_test.go index aaf2b82b..448cf145 100644 --- a/hub/internal/api/chaosnight_events_test.go +++ b/hub/internal/api/chaosnight_events_test.go @@ -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) + } + } + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 49f42d23..a6441d38 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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 diff --git a/hub/internal/claim/engine.go b/hub/internal/claim/engine.go index 860428a0..113700ff 100644 --- a/hub/internal/claim/engine.go +++ b/hub/internal/claim/engine.go @@ -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 diff --git a/hub/internal/claim/seed_test.go b/hub/internal/claim/seed_test.go index 5067a8a2..03238085 100644 --- a/hub/internal/claim/seed_test.go +++ b/hub/internal/claim/seed_test.go @@ -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) + } + } +} diff --git a/hub/internal/i18n/locales/en.json b/hub/internal/i18n/locales/en.json index 08abfed7..63512f8c 100644 --- a/hub/internal/i18n/locales/en.json +++ b/hub/internal/i18n/locales/en.json @@ -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" } diff --git a/hub/internal/i18n/locales/hu.json b/hub/internal/i18n/locales/hu.json index 3c36ed79..bcc1337f 100644 --- a/hub/internal/i18n/locales/hu.json +++ b/hub/internal/i18n/locales/hu.json @@ -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" } diff --git a/hub/internal/notify/app_update_events_test.go b/hub/internal/notify/app_update_events_test.go new file mode 100644 index 00000000..b2ab91e9 --- /dev/null +++ b/hub/internal/notify/app_update_events_test.go @@ -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) + } +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 8f2798c4..b3e737c5 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -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() diff --git a/hub/internal/notify/mail_golden_test.go b/hub/internal/notify/mail_golden_test.go index a5eb024a..31df1aab 100644 --- a/hub/internal/notify/mail_golden_test.go +++ b/hub/internal/notify/mail_golden_test.go @@ -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", diff --git a/hub/internal/notify/r389_cooldown_grain_test.go b/hub/internal/notify/r389_cooldown_grain_test.go index 7e9ab7a5..7886c403 100644 --- a/hub/internal/notify/r389_cooldown_grain_test.go +++ b/hub/internal/notify/r389_cooldown_grain_test.go @@ -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) } diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index fbf3060b..f46ed192 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -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 +} diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_held.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_held.txt new file mode 100644 index 00000000..ef7b3b15 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_held.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_undone.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_undone.txt new file mode 100644 index 00000000..33990751 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_event_app_update_undone.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_held_names_the_app.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_held_names_the_app.txt new file mode 100644 index 00000000..7b79fff6 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_held_names_the_app.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_undone_names_the_app.txt b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_undone_names_the_app.txt new file mode 100644 index 00000000..4837ee2a --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/en/customer_shape_app_update_undone_names_the_app.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_held.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_held.txt new file mode 100644 index 00000000..4e00f9a0 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_held.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_undone.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_undone.txt new file mode 100644 index 00000000..e2e0d79c --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_event_app_update_undone.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_held_names_the_app.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_held_names_the_app.txt new file mode 100644 index 00000000..70f5de50 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_held_names_the_app.txt @@ -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 diff --git a/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_undone_names_the_app.txt b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_undone_names_the_app.txt new file mode 100644 index 00000000..9ed9f942 --- /dev/null +++ b/hub/internal/notify/testdata/mail_goldens/hu/customer_shape_app_update_undone_names_the_app.txt @@ -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 diff --git a/hub/internal/store/app_update_seed_test.go b/hub/internal/store/app_update_seed_test.go new file mode 100644 index 00000000..3dd836a5 --- /dev/null +++ b/hub/internal/store/app_update_seed_test.go @@ -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 +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index e7a94822..b213c03b 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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