Phase 7: daily summary and read-only Hub API (AUD-73)

- internal/hub: project side of the frozen ask-contract. GET /capabilities,
  read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify
  client.
- internal/apprise: daily-summary delivery through the house Apprise API.
- internal/summary: deterministic plain-text daily summary builder.
- internal/store: CountByCategory, MessagesByCategory, and summary CRUD.
- internal/server: /capabilities and /ask (bearer-token protected), plus the
  Reports screen (/reports, build, send, delete).
- main.go: -summary, -summary-send, and -notify CLI entry points for n8n.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 22:38:49 +11:00
parent c4213ebed1
commit a349b42b1f
24 changed files with 2454 additions and 1 deletions

165
internal/server/reports.go Normal file
View File

@@ -0,0 +1,165 @@
// Reports screen and daily-summary delivery for Phase 7 (FR-10).
//
// The Reports screen (AGENTS.md §5) lists stored daily summaries, can build
// today's summary on demand, can deliver it by email through Apprise, and can
// delete a stored report. Building never sends mail; only the explicit "Send
// now" action does, and only when Apprise is configured.
package server
import (
"net/http"
"net/url"
"strconv"
"strings"
)
type reportRow struct {
ID int64
Date string
Text string
Delivered string
CreatedAt string
}
type reportsData struct {
Summaries []reportRow
Note string
Error string
CSRFToken string
AppriseReady bool
HubReady bool
}
// handleReports renders the list of stored summaries.
func (s *Server) handleReports(w http.ResponseWriter, r *http.Request) {
s.renderReports(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
}
func (s *Server) renderReports(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
summaries, err := s.store.ListSummaries(r.Context(), 60)
if err != nil {
s.logger.Error("list summaries", "error", err)
http.Error(w, "could not load reports", http.StatusInternalServerError)
return
}
rows := make([]reportRow, 0, len(summaries))
for _, sum := range summaries {
rows = append(rows, reportRow{
ID: sum.ID,
Date: sum.Date,
Text: sum.Text,
Delivered: sum.DeliveredTo,
CreatedAt: sum.CreatedAt,
})
}
data := reportsData{
Summaries: rows,
Note: note,
Error: errMsg,
CSRFToken: s.csrfToken(w, r),
AppriseReady: s.cfg.AppriseConfigured(),
HubReady: s.cfg.HubConfigured(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := s.tmpl.ExecuteTemplate(w, "reports.html", data); err != nil {
s.logger.Error("render reports", "error", err)
}
}
// handleSummaryGenerate builds and stores today's summary. It never sends mail.
func (s *Server) handleSummaryGenerate(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
data, err := s.summaries.Build(r.Context())
if err != nil {
s.logger.Error("build summary", "error", err)
s.redirectReports(w, r, "", "Could not build the summary: "+err.Error())
return
}
id, err := s.store.SaveSummary(r.Context(), data.Date, data.Text(), "")
if err != nil {
s.logger.Error("save summary", "error", err)
s.redirectReports(w, r, "", "Could not store the summary: "+err.Error())
return
}
s.logAction(r.Context(), "summary_generate", "Sam",
"date="+data.Date+" id="+strconv.FormatInt(id, 10)+
" inbox="+strconv.Itoa(data.InboxTotal)+
" needs_attention="+strconv.Itoa(data.NeedsAttention()))
s.redirectReports(w, r, "Built the summary for "+data.Date+". Nothing was sent.", "")
}
// handleSummarySend builds, stores, and emails today's summary through Apprise.
// It is the only path that sends mail, and it runs only when Sam clicks Send.
func (s *Server) handleSummarySend(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if !s.cfg.AppriseConfigured() {
s.redirectReports(w, r, "", "Apprise is not configured: set APPRISE_URL and APPRISE_KEY (or APPRISE_URLS).")
return
}
data, err := s.summaries.Build(r.Context())
if err != nil {
s.logger.Error("build summary", "error", err)
s.redirectReports(w, r, "", "Could not build the summary: "+err.Error())
return
}
id, err := s.store.SaveSummary(r.Context(), data.Date, data.Text(), "")
if err != nil {
s.logger.Error("save summary", "error", err)
s.redirectReports(w, r, "", "Could not store the summary: "+err.Error())
return
}
if err := s.apprise.Send(r.Context(), data.Subject(), data.Text()); err != nil {
s.logger.Error("deliver summary", "error", err)
s.redirectReports(w, r, "", "Apprise delivery failed: "+err.Error())
return
}
if err := s.store.MarkSummaryDelivered(r.Context(), id, s.cfg.SummaryTo); err != nil {
s.logger.Error("mark summary delivered", "error", err)
}
s.logAction(r.Context(), "summary_send", "Sam",
"date="+data.Date+" id="+strconv.FormatInt(id, 10)+" to="+s.cfg.SummaryTo)
s.redirectReports(w, r, "Sent the "+data.Date+" summary to "+s.cfg.SummaryTo+".", "")
}
// handleSummaryDelete removes one stored report (AGENTS.md §5 delete-report).
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.redirectReports(w, r, "", "Could not read the form.")
return
}
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil || id < 1 {
s.redirectReports(w, r, "", "Unknown report.")
return
}
if err := s.store.DeleteSummary(r.Context(), id); err != nil {
s.logger.Error("delete summary", "error", err)
s.redirectReports(w, r, "", "Could not delete the report: "+err.Error())
return
}
s.logAction(r.Context(), "summary_delete", "Sam", "id="+strconv.FormatInt(id, 10))
s.redirectReports(w, r, "Deleted report "+strconv.FormatInt(id, 10)+".", "")
}
// redirectReports is POST/redirect/GET so a refresh does not resubmit.
func (s *Server) redirectReports(w http.ResponseWriter, r *http.Request, note, errMsg string) {
q := url.Values{}
if note != "" {
q.Set("note", note)
}
if errMsg != "" {
q.Set("error", errMsg)
}
http.Redirect(w, r, "/reports?"+q.Encode(), http.StatusSeeOther)
}