diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index d38c994..6784b71 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -105,6 +105,7 @@ func run() error { runner.Push = api.PushToUser runner.SendBeacon = api.SendBeacon runner.DailyPlans = api.DailyPlans + runner.NotifyOfUpdates = api.NotifyOfUpdates runner.Start(jobCtx) srv := &http.Server{ diff --git a/internal/httpapi/update_notice.go b/internal/httpapi/update_notice.go new file mode 100644 index 0000000..93d840f --- /dev/null +++ b/internal/httpapi/update_notice.go @@ -0,0 +1,91 @@ +package httpapi + +import ( + "context" + "strings" + + "github.com/kristianwind/verdande/internal/store" + "github.com/kristianwind/verdande/internal/update" +) + +// Besked til administratorerne, når der er kommet en ny version. +// +// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en +// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde +// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af +// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter. +// +// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om, +// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan +// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder. + +const updateScope = "update" + +// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer, +// så en time mellem opslagene her koster ikke noget — og en instans, der har været +// slukket, opdager det ved den første time, den er vågen. +func (s *Server) NotifyOfUpdates(ctx context.Context) error { + return s.notifyOfUpdate(ctx, s.updates.Status(ctx)) +} + +// notifyOfUpdate tager status som argument frem for at hente den, så den kan +// prøves uden at spørge GitHub om noget. +func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error { + // Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke + // blive til en besked ad bagvejen. + if status.Disabled || !status.Available || status.Latest == "" { + return nil + } + + stored, err := s.db.InstanceSettings(ctx, updateScope) + if err != nil { + return err + } + // Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det + // samme er den slags besked, folk slår fra — og så er den næste, der betyder + // noget, også slået fra. + if last, _ := stored["notified_version"].(string); last == status.Latest { + return nil + } + + users, err := s.db.ListUsers(ctx) + if err != nil { + return err + } + title := updateTitle(status.Latest) + body := firstLineOf(status.Notes) + for _, u := range users { + if !u.IsAdmin { + continue + } + s.notifyDirect(ctx, &store.Notification{ + UserID: u.ID, Kind: "update.available", Title: title, Body: body, + }) + } + + // Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en + // fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde. + return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{ + "notified_version": status.Latest, + }) +} + +// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og +// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra +// `kind` — se KINDS i Notifications.svelte. +func updateTitle(version string) string { + return "verdande " + version + " er klar" +} + +// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop. +// +// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det, +// en notifikation er til: den skal sige, at der er noget, og hvor man læser det. +func firstLineOf(notes string) string { + for _, line := range strings.Split(notes, "\n") { + if line = strings.TrimSpace(line); line != "" { + return firstWords(line, 30) + } + } + return "" +} diff --git a/internal/httpapi/update_notice_test.go b/internal/httpapi/update_notice_test.go new file mode 100644 index 0000000..3c63b43 --- /dev/null +++ b/internal/httpapi/update_notice_test.go @@ -0,0 +1,127 @@ +package httpapi + +import ( + "testing" + + "github.com/kristianwind/verdande/internal/update" +) + +// En ny version siger til af sig selv. Uden det stod svaret nederst på en +// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at +// der var noget at se efter. +func TestANewVersionTellsTheAdministrator(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + status := update.Status{ + Current: "v0.44.0", Latest: "v0.45.0", Available: true, + Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0", + } + if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil { + t.Fatalf("besked om ny version: %v", err) + } + + list, unread := notifications(t, ts) + if len(list) != 1 || unread != 1 { + t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread) + } + n := firstOf(t, list) + if n["kind"] != "update.available" { + t.Errorf("besked = %v", n) + } + // Titlen bærer versionen, fordi den også bliver til en push-besked, og push + // har ingen ordbog at slå sætningen op i. + if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" { + t.Errorf("titel = %q", title) + } + + // Én besked pr. version, ikke én pr. time. En påmindelse hver time om det + // samme er den slags besked, folk slår fra — og så er den næste, der betyder + // noget, også slået fra. + if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil { + t.Fatal(err) + } + if list, _ := notifications(t, ts); len(list) != 1 { + t.Errorf("den samme version gav %d beskeder, want 1", len(list)) + } + + // Og den næste version er en ny ting at få at vide. + status.Latest = "v0.46.0" + if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil { + t.Fatal(err) + } + if list, _ := notifications(t, ts); len(list) != 2 { + t.Errorf("en ny version gav %d beskeder, want 2", len(list)) + } +} + +// Kun administratorer. En besked om, at serveren er bagud, er en besked om en +// andens arbejde, hvis man ikke selv kan gøre noget ved den. +func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + other := ts.newUser(t, "sofie@example.dk", "Sofie") + + err := ts.api.notifyOfUpdate(t.Context(), update.Status{ + Current: "v0.44.0", Latest: "v0.45.0", Available: true, + }) + if err != nil { + t.Fatal(err) + } + + if list, _ := notifications(t, other); len(list) != 0 { + t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list)) + } + if list, _ := notifications(t, ts); len(list) != 1 { + t.Errorf("administratoren fik %d beskeder, want 1", len(list)) + } +} + +// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen. +func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + for _, status := range []update.Status{ + {Current: "v0.44.0", Disabled: true}, + {Current: "v0.44.0", Available: false}, + {Current: "v0.44.0", Available: true, Latest: ""}, + } { + if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil { + t.Fatalf("%v: %v", status, err) + } + } + if list, _ := notifications(t, ts); len(list) != 0 { + t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list)) + } +} + +// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det, +// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne +// klikkes. +func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + err := ts.api.notifyOfUpdate(t.Context(), update.Status{ + Current: "v0.44.0", Latest: "v0.45.0", Available: true, + }) + if err != nil { + t.Fatal(err) + } + n := firstOf(t, mustNotifications(t, ts)) + for _, key := range []string{"note_id", "task_id", "project_id"} { + if v, ok := n[key]; ok && v != "" { + t.Errorf("%s = %v, want ingenting", key, v) + } + } +} + +func mustNotifications(t *testing.T, ts *testServer) []any { + t.Helper() + list, _ := notifications(t, ts) + if len(list) == 0 { + t.Fatal("ingen beskeder") + } + return list +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 1fcaf2c..b532c32 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -54,6 +54,9 @@ type Runner struct { // AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene. DailyPlans func(ctx context.Context) error + // NotifyOfUpdates tells the administrators when a newer version is out. + NotifyOfUpdates func(ctx context.Context) error + // SendBeacon reports this installation to the collector, if it is switched on // and a day has passed. Supplied by the HTTP layer for the same reason as the // syncs: that is where the instance settings and the version live. @@ -98,6 +101,9 @@ func (r *Runner) Start(ctx context.Context) { // form som ovenstående, og af samme grund. Timen er personens egen: klokken // syv er syv forskellige steder. r.every(ctx, "dailyplan", time.Hour, r.dailyPlans) + // Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i + // seks timer, og beskeden sendes én gang pr. version. + r.every(ctx, "updates", time.Hour, r.updateNotices) } func (r *Runner) Wait() { r.wg.Wait() } @@ -132,6 +138,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration, }() } +func (r *Runner) updateNotices(ctx context.Context) error { + if r.NotifyOfUpdates == nil { + return nil + } + return r.NotifyOfUpdates(ctx) +} + func (r *Runner) dailyPlans(ctx context.Context) error { if r.DailyPlans == nil { return nil diff --git a/web/src/lib/components/Notifications.svelte b/web/src/lib/components/Notifications.svelte index fb1ae47..91e5eef 100644 --- a/web/src/lib/components/Notifications.svelte +++ b/web/src/lib/components/Notifications.svelte @@ -26,6 +26,7 @@ 'note.shared': 'notif.noteShared', 'note.mention': 'notif.mention', 'daily.plan': 'notif.dailyPlan', + 'update.available': 'notif.update', comment: 'notif.comment' }; @@ -52,6 +53,9 @@ function follow(n) { open = false; if (!n.read) app.markRead(n.id); + // En ny version hører ingen steder hen i data — den fører hen, hvor knappen + // til at gøre noget ved den sidder. + if (n.kind === 'update.available') return goto('/indstillinger/notifikationer'); if (n.note_id) goto(`/noter?note=${n.note_id}`); else if (n.task_id) app.openDetail(n.task_id); else if (n.project_id) goto(`/projekt/${n.project_id}`); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 18ff8b8..2ed8ec3 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -33,6 +33,7 @@ export const da = { 'notif.noteShared': '{name} delte en note med dig', 'notif.mention': '{name} nævnte dig i en note', 'notif.dailyPlan': 'Dagens plan', + 'notif.update': 'Der er kommet en ny version', 'notif.comment': '{name} kommenterede', 'notif.justNow': 'lige nu', 'notif.minutes': '{n} min. siden', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 217138a..a83686c 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -30,6 +30,7 @@ export const en = { 'notif.noteShared': '{name} shared a note with you', 'notif.mention': '{name} mentioned you in a note', 'notif.dailyPlan': "Today's plan", + 'notif.update': 'A new version is out', 'notif.comment': '{name} commented', 'notif.justNow': 'just now', 'notif.minutes': '{n} min ago',