diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 53aa286..45db011 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -54,7 +54,9 @@ type fakeCore struct { revertErr error // for handleNotifications tests - nudgesErr error + nudgesErr error + attempts []ipc.DeliveryAttempt + attemptStatus string // for handleHistory tests historyFacts []ipc.Fact @@ -1251,3 +1253,35 @@ func TestHandleWS_AssertedSession_PassesGate(t *testing.T) { t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String()) } } + +func (f *fakeCore) DeliveryAttempts(_ context.Context, status string, _ int) ([]ipc.DeliveryAttempt, error) { + f.attemptStatus = status + return f.attempts, nil +} + +// TestHandleNotifications_ShowsTheOutbox — the outbox was written and never +// read, so a dropped or failed send was invisible (Vikunja #390). +func TestHandleNotifications_ShowsTheOutbox(t *testing.T) { + done := time.Date(2026, 8, 4, 9, 0, 30, 0, time.UTC) + core := &fakeCore{ + attempts: []ipc.DeliveryAttempt{ + {Kind: "nudge", Rule: "care-check", Channel: "telegram", Status: "dropped", + Created: done.Add(-30 * time.Second), Completed: &done}, + {Kind: "reminder", ReminderID: 7, Channel: "voice", Status: "pending", Created: done}, + }, + } + rr := httptest.NewRecorder() + handleNotifications(rr, httptest.NewRequest(http.MethodGet, "/notifications?status=dropped", nil), core) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if core.attemptStatus != "dropped" { + t.Errorf("status filter = %q, want it passed through", core.attemptStatus) + } + body := rr.Body.String() + for _, want := range []string{"care-check", "dropped", "reminder #7", "Delivery outbox"} { + if !strings.Contains(body, want) { + t.Errorf("rendered outbox missing %q", want) + } + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a80d951..9d8368c 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -893,12 +893,59 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway) return } + // The outbox, on the page that already answers "what did she send". + // A failed or dropped attempt is why she went quiet, and until now it was + // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. + status := r.URL.Query().Get("status") + attempts, err := core.DeliveryAttempts(ctx, status, 50) + if err != nil { + // The nudge list is still worth showing, so this is a note on the page + // rather than a dead page. + log.Printf("notifications: delivery attempts: %v", err) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil { + if err := notificationsTmpl.Execute(w, map[string]any{ + "Nudges": nudges, + "Attempts": deliveryRows(attempts), + "Status": status, + }); err != nil { log.Printf("notifications template: %v", err) } } +// deliveryRow is one outbox line, with every timestamp already formatted so +// the template holds no date logic — same shape as taskRow. +type deliveryRow struct { + Kind string + Target string + Channel string + Status string + Created string + Completed string +} + +func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { + out := make([]deliveryRow, 0, len(as)) + for _, a := range as { + target := a.Rule + if target == "" && a.ReminderID != 0 { + target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) + } + row := deliveryRow{ + Kind: a.Kind, + Target: target, + Channel: a.Channel, + Status: a.Status, + Created: a.Created.Format("02.01 15:04"), + } + if a.Completed != nil { + row.Completed = a.Completed.Format("15:04") + } + out = append(out, row) + } + return out +} + func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) diff --git a/cmd/mavweb/notifications.html b/cmd/mavweb/notifications.html index 8d81cd9..4ebf044 100644 --- a/cmd/mavweb/notifications.html +++ b/cmd/mavweb/notifications.html @@ -14,5 +14,27 @@
no notifications yet
check back later or ask maven a question
{{end}} +

Delivery outbox

+

+ every send is recorded before it leaves, so a failure is visible rather than silent. + all · + dropped · + failed · + pending · + unknown +

+{{if .Attempts}}
+ +{{range .Attempts}} + + + + + + +{{end}}
startedkindrulechannelstatusfinished
{{.Created}}{{.Kind}}{{.Target}}{{.Channel}}{{.Status}}{{.Completed}}
+{{else}}
+
no delivery attempts{{if .Status}} with status {{.Status}}{{end}}
+
{{end}} {{template "shellBottom"}} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index eb3def0..1eb8178 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -60,6 +60,19 @@ type Nudge struct { OutcomeTs *int64 `json:"outcome_ts,omitempty"` } +// DeliveryAttempt — one row of the delivery outbox. Times are formatted by the +// reader; Completed is nil while the attempt is still pending. +type DeliveryAttempt struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Rule string `json:"rule,omitempty"` + ReminderID int64 `json:"reminder_id,omitempty"` + Channel string `json:"channel"` + Status string `json:"status"` + Created time.Time `json:"created"` + Completed *time.Time `json:"completed,omitempty"` +} + // Note — a recall/preference item; ranked by embedding cosine on query. // Score is set by QueryNotes (0 on the write path). type Note struct { @@ -521,6 +534,12 @@ type outcomesReq struct { type nReq struct { N int `json:"n"` } + +// deliveryAttemptsReq — the outbox read. Status is empty for every status. +type deliveryAttemptsReq struct { + Status string `json:"status,omitempty"` + N int `json:"n"` +} type kindNReq struct { Kind string `json:"kind"` N int `json:"n"` @@ -685,6 +704,9 @@ type CoreAPI interface { RecentActiveFactsByKind(ctx context.Context, kind string, n int) ([]Fact, error) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) RecentNudges(ctx context.Context, n int) ([]Nudge, error) + // DeliveryAttempts reads the outbox, newest first. An empty status means + // every status (Vikunja #390). + DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) // RecentEcosystemTraces reads the ecosystem call log, which lives in its // own table so machine-rate traces never crowd out human-rate facts. diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 4cba25c..4a36322 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -68,6 +68,7 @@ var readOnlyMethods = map[Method]bool{ MethodRecentActiveFacts: true, MethodCalendarEvents: true, MethodRecentNudges: true, + MethodDeliveryAttempts: true, MethodRecentEcoTraces: true, MethodQueryNotes: true, MethodRecentNotes: true, @@ -373,6 +374,14 @@ func (c *Client) RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemT return out, nil } +func (c *Client) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + var out []DeliveryAttempt + if err := c.call(ctx, MethodDeliveryAttempts, deliveryAttemptsReq{Status: status, N: n}, &out); err != nil { + return nil, err + } + return out, nil +} + func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { var out []Nudge if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index f9b2692..ae596a5 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -173,6 +173,25 @@ func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { return out, nil } +func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + as, err := a.s.ListDeliveryAttempts(ctx, status, n) + if err != nil { + return nil, mapErr(err) + } + out := make([]DeliveryAttempt, len(as)) + for i, at := range as { + out[i] = DeliveryAttempt{ + ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID, + Channel: at.Channel, Status: at.Status, Created: at.Created, + } + if at.HasComplete { + t := at.Completed + out[i].Completed = &t + } + } + return out, nil +} + func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { id, err := a.s.WriteNote(ctx, ts, text, embedding, source) return id, mapErr(err) @@ -863,6 +882,16 @@ var methodTable = map[Method]handlerFunc{ } return out, nil }), + MethodDeliveryAttempts: withParams(func(ctx context.Context, api CoreAPI, p deliveryAttemptsReq) ([]DeliveryAttempt, error) { + out, err := api.DeliveryAttempts(ctx, p.Status, p.N) + if err != nil { + return nil, err + } + if out == nil { + out = []DeliveryAttempt{} + } + return out, nil + }), MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) { out, err := api.RecentNudges(ctx, p.N) if err != nil { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 45575f3..4279c67 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -68,6 +68,9 @@ func (UnimplementedCoreAPI) RecentActiveFactsByKind(ctx context.Context, kind st func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { return nil, ErrNotImplemented } +func (UnimplementedCoreAPI) DeliveryAttempts(ctx context.Context, status string, n int) ([]DeliveryAttempt, error) { + return nil, ErrNotImplemented +} func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { return nil, ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index c55a7ec..50e2f15 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -28,6 +28,7 @@ const ( MethodRecentActiveFacts Method = "recent_active_facts_by_kind" MethodCalendarEvents Method = "calendar_events" MethodRecentNudges Method = "recent_nudges" + MethodDeliveryAttempts Method = "delivery_attempts" MethodRecentEcoTraces Method = "recent_ecosystem_traces" MethodWriteNote Method = "write_note" MethodQueryNotes Method = "query_notes" diff --git a/internal/store/delivery.go b/internal/store/delivery.go index b0b46df..4c016c7 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -87,3 +87,68 @@ func (s *Store) ReconcileStaleDeliveryAttempts(ctx context.Context, now time.Tim } return int(n), nil } + +// DeliveryAttempt — one row of the outbox, as a reader sees it. +type DeliveryAttempt struct { + ID int64 + Kind string // nudge|reminder + Rule string // set for nudges + ReminderID int64 // set for reminders + Channel string + Status string // one of the Delivery* constants + Created time.Time + Completed time.Time // zero while pending + HasComplete bool +} + +// ListDeliveryAttempts returns recent attempts, newest first. An empty status +// means every status; anything else filters on it. +// +// The table was write-only until 04-08-2026: rows were recorded and nothing +// could read them, so the tests for #368 and #370 had to reach past the store +// into store.DB, which is the tell (Vikunja #390). A durable record nobody can +// read answers no question, and "why did Maven go quiet" is supposed to be a +// query rather than a mystery. +// +// Status is the filter that earns its place, because the two questions actually +// asked are "what got dropped" and "what is still pending". Neither is +// answerable by reading the whole list on a busy day. +func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit int) ([]DeliveryAttempt, error) { + if limit <= 0 { + limit = 50 + } + q := `SELECT id, kind, rule, reminder_id, channel, status, created_ts, completed_ts + FROM delivery_attempts` + args := []any{} + if status != "" { + q += ` WHERE status = ?` + args = append(args, status) + } + q += ` ORDER BY created_ts DESC, id DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list delivery attempts: %w", err) + } + defer rows.Close() + + var out []DeliveryAttempt + for rows.Next() { + var a DeliveryAttempt + var created int64 + var completed *int64 + if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.Channel, &a.Status, &created, &completed); err != nil { + return nil, fmt.Errorf("list delivery attempts: scan: %w", err) + } + a.Created = time.UnixMilli(created) + if completed != nil { + a.Completed, a.HasComplete = time.UnixMilli(*completed), true + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list delivery attempts: %w", err) + } + return out, nil +} diff --git a/internal/store/delivery_test.go b/internal/store/delivery_test.go index 4a7e23d..d261448 100644 --- a/internal/store/delivery_test.go +++ b/internal/store/delivery_test.go @@ -32,3 +32,53 @@ func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) { t.Fatalf("status: want %q, got %q", DeliveryDropped, status) } } + +// TestListDeliveryAttempts — the read path the outbox lacked until #390. The +// two questions it must answer are "what was dropped" and "what is pending". +func TestListDeliveryAttempts(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) + + sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "telegram", "h1", base) + if err != nil { + t.Fatal(err) + } + if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil { + t.Fatal(err) + } + dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "telegram", "h2", base.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "voice", "h3", base.Add(2*time.Minute)); err != nil { + t.Fatal(err) + } + + all, err := s.ListDeliveryAttempts(ctx, "", 10) + if err != nil || len(all) != 3 { + t.Fatalf("ListDeliveryAttempts = %d rows, err=%v, want 3", len(all), err) + } + // Newest first. + if all[0].Kind != "reminder" || all[0].ReminderID != 7 { + t.Fatalf("newest row is %+v, want the reminder", all[0]) + } + if all[0].HasComplete { + t.Fatalf("a pending row must have no completion time: %+v", all[0]) + } + if !all[2].HasComplete || !all[2].Completed.Equal(base.Add(time.Second)) { + t.Fatalf("completed row lost its time: %+v", all[2]) + } + + only, err := s.ListDeliveryAttempts(ctx, DeliveryDropped, 10) + if err != nil || len(only) != 1 || only[0].Rule != "care" { + t.Fatalf("dropped filter = %+v, err=%v", only, err) + } + pending, err := s.ListDeliveryAttempts(ctx, DeliveryPending, 10) + if err != nil || len(pending) != 1 || pending[0].Kind != "reminder" { + t.Fatalf("pending filter = %+v, err=%v", pending, err) + } +}