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