package web
import (
"net/http"
"strconv"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// inboxData is the review-queue page: the open proposals waiting on the owner,
// and the digest of what auto-merged without stopping for review.
//
// NewCount is how many leading digest rows auto-merged since the owner last
// marked it seen. The digest is newest-first and "new" means merged after the
// mark, so the new rows are exactly the first NewCount — the template draws the
// "since you last looked" divider after them and shows the mark-as-seen action
// only when there is something new to clear.
type inboxData struct {
Open []proposalRow
Digest []proposalRow
NewCount int
}
// proposalRow is one proposal as a listing line: enough to decide whether to
// open it, and the link that does. New marks a digest row that auto-merged
// since the owner last looked; it is always false for the open queue.
type proposalRow struct {
Href string
ID int
Space string
Title string
Agent string
Approval string
New bool
}
// handleInbox renders the review queue: every open proposal on the instance,
// plus the digest of recently policy-merged content. It is the backstop the
// design describes — the link an agent hands you is the normal way in, and this
// catches the work no link reached.
func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
if !s.allowRead(w, r, formatHTML) {
return
}
open, err := s.reader.Inbox(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
digest, err := s.reader.Digest(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
// The GET stays pure: it reads the mark to draw the divider but never moves
// it. Advancing is handleInboxSeen's job, behind a POST.
mark, marked, err := s.reader.DigestMark(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
digestRows, newCount := digestRows(digest, mark, marked)
vd := s.view(r, "Review queue")
vd.Data = inboxData{
Open: proposalRows(open),
Digest: digestRows,
NewCount: newCount,
}
s.render(w, http.StatusOK, "inbox", vd)
}
// handleInboxSeen advances the owner's digest mark to now, then redirects back
// to the queue so a reload does not re-submit. It is the one write the review
// queue makes; keeping it a POST is what lets handleInbox stay a pure read.
//
// Only the owner may move their own mark, and the cross-site guard is the same
// one approve/reject use — the CSRF defense a form post needs when the session
// cookie is meta's and this service cannot set its SameSite.
func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
if !authn.PrincipalFromContext(r.Context()).IsOwner() {
s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
return
}
if !s.sameOrigin(r) {
s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
return
}
if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
s.fail(w, r, err)
return
}
http.Redirect(w, r, "/inbox", http.StatusSeeOther)
}
// proposalRows turns service proposals into listing rows for the open queue,
// where nothing is ever "new".
func proposalRows(ps []service.Proposal) []proposalRow {
rows := make([]proposalRow, 0, len(ps))
for _, p := range ps {
rows = append(rows, proposalRowOf(p))
}
return rows
}
// digestRows turns the digest proposals into rows, flagging each that
// auto-merged after the mark as new and counting them. With no mark yet
// (marked false) the whole digest is new — the owner has never cleared it. The
// digest arrives newest-first and a row is new iff its merge time is after the
// mark, so the new rows are the leading run and newCount is their length.
func digestRows(ps []service.Proposal, mark time.Time, marked bool) ([]proposalRow, int) {
rows := make([]proposalRow, 0, len(ps))
newCount := 0
for _, p := range ps {
row := proposalRowOf(p)
row.New = !marked || (p.Resolved != nil && p.Resolved.After(mark))
if row.New {
newCount++
}
rows = append(rows, row)
}
return rows, newCount
}
// proposalRowOf builds one listing row, deriving its link from the space and id
// — the same stable /~owner/space/p/<id> shape the write plane hands back.
func proposalRowOf(p service.Proposal) proposalRow {
return proposalRow{
Href: proposalHref(p),
ID: p.ID,
Space: p.Space.String(),
Title: p.Title,
Agent: p.Agent,
Approval: string(p.Approval),
}
}
// proposalHref is the review-page link for a proposal: the same path the
// proposal URL uses, minus the origin, so it works as a relative link in the UI.
func proposalHref(p service.Proposal) string {
return "/" + p.Space.String() + "/p/" + strconv.Itoa(p.ID)
}