Files
Agent-System/internal/server/supplement_impact_handlers.go
T
weichen 2e56ba9719 feat(docgov): complete review lifecycle gaps for lock/notify/comment/reopen
Defer locked-period supplements to a pending ledger, route duty-holder
notifications, persist stage-scoped comments, and prefer Approver for reopen.
2026-09-25 20:08:56 +00:00

164 lines
5.9 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
)
// SupplementImpactService is the HTTP-facing port for supplement impact batches.
// Satisfied by an adapter over *supplementimpact.Service when wired; nil → 503.
type SupplementImpactService interface {
GetBatch(ctx context.Context, batchID string) (*SupplementImpactBatchView, error)
Decide(ctx context.Context, batchID string, actorUserID int64, in SupplementImpactDecisionRequest) (*SupplementImpactBatchView, error)
}
// SupplementImpactBatchView is the card-refresh / decision response shape.
type SupplementImpactBatchView struct {
BatchID string `json:"batch_id"`
ProjectID int64 `json:"project_id"`
SessionID string `json:"session_id,omitempty"`
InitiatorUserID int64 `json:"initiator_user_id"`
SourceKind string `json:"source_kind,omitempty"`
SourceSummary string `json:"source_summary,omitempty"`
CoverageStatus string `json:"coverage_status,omitempty"`
Status string `json:"status"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest,omitempty"`
Revision int64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
Items []SupplementImpactItemView `json:"items,omitempty"`
}
// SupplementImpactItemView is one affected document row on the confirm card.
type SupplementImpactItemView struct {
DocID string `json:"doc_id"`
Title string `json:"title,omitempty"`
ItemKey string `json:"item_key,omitempty"`
ImpactType string `json:"impact_type,omitempty"`
Depth int `json:"depth,omitempty"`
Reason string `json:"reason,omitempty"`
AuthorClaimedSnapshot bool `json:"author_claimed_snapshot"`
BusySnapshot bool `json:"busy_snapshot"`
LockedSnapshot bool `json:"locked_snapshot"`
ChangeSummary string `json:"change_summary,omitempty"`
Selected bool `json:"selected"`
Outcome string `json:"outcome,omitempty"`
ProposalID string `json:"proposal_id,omitempty"`
}
// SupplementImpactDecisionRequest is the confirm/cancel CAS envelope.
type SupplementImpactDecisionRequest struct {
Kind string `json:"kind"`
SelectedDocIDs []string `json:"selected_doc_ids"`
DecisionNonce string `json:"decision_nonce"`
DecisionDigest string `json:"decision_digest"`
ExpectedRevision int64 `json:"expected_revision"`
}
func (s *Server) handleGetSupplementImpact(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.supplementImpact == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable")
return
}
batchID := strings.TrimSpace(r.PathValue("batch_id"))
if batchID == "" {
writeSafeError(w, r, http.StatusBadRequest, "invalid batch id")
return
}
batch, err := s.supplementImpact.GetBatch(r.Context(), batchID)
if err != nil || batch == nil {
writeSafeError(w, r, http.StatusNotFound, "batch not found")
return
}
if !s.userCanAccessSupplementBatch(r.Context(), user.ID, batch.ProjectID) {
writeSafeError(w, r, http.StatusForbidden, "not a project member")
return
}
writeJSONResponse(w, http.StatusOK, batch)
}
func (s *Server) handleDecideSupplementImpact(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAuthenticatedUser(w, r)
if !ok {
return
}
if s.supplementImpact == nil {
writeSafeError(w, r, http.StatusServiceUnavailable, "supplement impact service unavailable")
return
}
batchID := strings.TrimSpace(r.PathValue("batch_id"))
if batchID == "" {
writeSafeError(w, r, http.StatusBadRequest, "invalid batch id")
return
}
var body SupplementImpactDecisionRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return
}
kind := strings.TrimSpace(strings.ToLower(body.Kind))
if kind != "confirm" && kind != "cancel" {
writeSafeError(w, r, http.StatusBadRequest, "kind must be confirm or cancel")
return
}
if strings.TrimSpace(body.DecisionNonce) == "" || strings.TrimSpace(body.DecisionDigest) == "" {
writeSafeError(w, r, http.StatusBadRequest, "decision_nonce and decision_digest required")
return
}
body.Kind = kind
// Load batch first for membership check via project_id.
existing, err := s.supplementImpact.GetBatch(r.Context(), batchID)
if err != nil || existing == nil {
writeSafeError(w, r, http.StatusNotFound, "batch not found")
return
}
if !s.userCanAccessSupplementBatch(r.Context(), user.ID, existing.ProjectID) {
writeSafeError(w, r, http.StatusForbidden, "not a project member")
return
}
batch, err := s.supplementImpact.Decide(r.Context(), batchID, user.ID, body)
if err != nil {
msg := err.Error()
status := http.StatusBadRequest
if strings.Contains(msg, "revision conflict") {
status = http.StatusConflict
} else if strings.Contains(msg, "only initiator") {
status = http.StatusForbidden
} else if strings.Contains(msg, "expired") {
status = http.StatusGone
} else if strings.Contains(msg, "not pending") {
status = http.StatusConflict
}
writeSafeError(w, r, status, msg)
return
}
writeJSONResponse(w, http.StatusOK, batch)
}
func (s *Server) userCanAccessSupplementBatch(ctx context.Context, userID, projectID int64) bool {
if projectID <= 0 || userID <= 0 {
return false
}
if s.authorizer() != nil {
ok, err := s.authorizer().IsProjectMember(ctx, projectID, userID)
if err == nil && ok {
return true
}
}
if s.pwSvc() != nil {
if isCreator, err := s.pwSvc().IsProjectCreator(ctx, projectID, userID); err == nil && isCreator {
return true
}
}
return false
}