Defer locked-period supplements to a pending ledger, route duty-holder notifications, persist stage-scoped comments, and prefer Approver for reopen.
164 lines
5.9 KiB
Go
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
|
|
}
|