package handlers import ( "encoding/json" "errors" "net/http" "github.com/go-chi/chi/v5" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models" "git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store" ) type ArgumentsHandler struct { topics *store.TopicStore camps *store.CampStore rounds *store.RoundStore arguments *store.ArgumentStore } func NewArgumentsHandler( t *store.TopicStore, c *store.CampStore, r *store.RoundStore, a *store.ArgumentStore, ) *ArgumentsHandler { return &ArgumentsHandler{topics: t, camps: c, rounds: r, arguments: a} } type postArgumentBody struct { Content string `json:"content"` } // POST /api/topics/{id}/arguments // // Agent-only. Caller must be allocated to one of the topic's camps; // rejected otherwise. Topic must be `debating` (status state machine // enforces; argument outside that window is meaningless). Content is // stored as-is (no markdown rendering server-side; frontend renders). // // Round: argument is attached to the LATEST open round. Round-advance // policy is the orchestrator's call (Phase 2D ships with manual/single // round 0; round bumping logic comes when the rule is decided). func (h *ArgumentsHandler) Post(w http.ResponseWriter, r *http.Request) { caller := auth.FromContext(r.Context()) if caller.Kind != auth.CallerAgent { http.Error(w, "argument posting is agent-only", http.StatusForbidden) return } topicID := chi.URLParam(r, "id") topic, err := h.topics.GetByID(r.Context(), topicID) if errors.Is(err, store.ErrNotFound) { http.Error(w, "topic not found", http.StatusNotFound) return } if err != nil { http.Error(w, "lookup failed", http.StatusInternalServerError) return } if topic.Status != models.TopicStatusDebating { http.Error(w, "topic not in debate window (status="+string(topic.Status)+")", http.StatusConflict) return } camp, err := h.camps.AgentCampInTopic(r.Context(), topicID, caller.ID) if err != nil { http.Error(w, "you are not allocated to any camp on this topic", http.StatusForbidden) return } round, err := h.rounds.Latest(r.Context(), topicID) if err != nil { http.Error(w, "no open round (orchestrator hasn't opened round 0 yet?)", http.StatusConflict) return } var body postArgumentBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } if body.Content == "" { http.Error(w, "content required", http.StatusBadRequest) return } const maxContent = 32_000 // arbitrary upper bound; arguments shouldn't be book-length if len(body.Content) > maxContent { http.Error(w, "content too long", http.StatusRequestEntityTooLarge) return } arg, err := h.arguments.Post(r.Context(), store.PostArgumentInput{ TopicID: topicID, RoundID: round.ID, Camp: camp, AgentID: caller.ID, Content: body.Content, }) if err != nil { http.Error(w, "post failed: "+err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, arg) } // GET /api/topics/{id}/arguments — full transcript in posted order. // Visibility: anonymous can read for public topics; private requires // any-auth (enforced upstream by middleware composition). func (h *ArgumentsHandler) List(w http.ResponseWriter, r *http.Request) { topicID := chi.URLParam(r, "id") topic, err := h.topics.GetByID(r.Context(), topicID) if errors.Is(err, store.ErrNotFound) { http.Error(w, "topic not found", http.StatusNotFound) return } if err != nil { http.Error(w, "lookup failed", http.StatusInternalServerError) return } caller := auth.FromContext(r.Context()) if caller.Kind == "" && topic.Visibility != models.VisibilityPublic { http.Error(w, "not found", http.StatusNotFound) return } rows, err := h.arguments.ListByTopic(r.Context(), topicID) if err != nil { http.Error(w, "list failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"arguments": rows, "count": len(rows)}) }