// 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) }