hub v0.120.0: app_update_undone/held reach the household, per app, in its language
gates / gates (push) Successful in 26s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+18
@@ -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
|
||||
+18
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
+18
@@ -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
|
||||
+18
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user