- 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>
166 lines
5.5 KiB
Go
166 lines
5.5 KiB
Go
// 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)
|
|
}
|