Adds the camps allocation array to topic_detail responses so an agent
can locate which camp they're in (pro/con/judge) in a single round-trip
instead of needing a separate endpoint call. Camps are 0 rows
pre-signup_close, exactly 3 rows after — small enough to inline always.
Backward-compatible: the existing Topic fields remain top-level on the
response; `camps` is a sibling array. Callers reading e.g. response.title
or response.status continue to work unchanged.
Arguments are deliberately NOT inlined here — they can grow to many KB
per topic, and most callers (list view, status check, signup intent
resolution) don't need them. Use the new `dialectic_list_arguments`
plugin tool against GET /api/topics/{id}/arguments when you actually
need the transcript.
E2e verified on sim: judge agent successfully called topic_detail to
get camps + list_arguments to get transcript + submit_verdict citing
the actual pro/con argument content (no more 'tie because I saw no
arguments' false readings).
269 lines
8.9 KiB
Go
269 lines
8.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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 TopicsHandler struct {
|
|
store *store.TopicStore
|
|
camps *store.CampStore
|
|
}
|
|
|
|
func NewTopicsHandler(s *store.TopicStore, c *store.CampStore) *TopicsHandler {
|
|
return &TopicsHandler{store: s, camps: c}
|
|
}
|
|
|
|
// GET /api/topics?status=...&visibility=...&limit=...&offset=...
|
|
//
|
|
// Visibility filter is enforced at the auth layer: anonymous callers
|
|
// only see visibility=public; authenticated users (CallerUser) see all
|
|
// they're entitled to (Phase 2 v1: all; Phase 4 may add per-user ACLs).
|
|
// Agent callers (CallerAgent) see all — they're acting as system on
|
|
// behalf of the platform.
|
|
func (h *TopicsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
caller := auth.FromContext(r.Context())
|
|
f := store.ListFilter{
|
|
Status: r.URL.Query().Get("status"),
|
|
Visibility: r.URL.Query().Get("visibility"),
|
|
}
|
|
if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 {
|
|
f.Limit = v
|
|
}
|
|
if v, _ := strconv.Atoi(r.URL.Query().Get("offset")); v > 0 {
|
|
f.Offset = v
|
|
}
|
|
|
|
// Anonymous: force visibility=public.
|
|
if caller.Kind == "" {
|
|
f.Visibility = string(models.VisibilityPublic)
|
|
}
|
|
|
|
rows, err := h.store.List(r.Context(), f)
|
|
if err != nil {
|
|
http.Error(w, "list failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"topics": rows, "count": len(rows)})
|
|
}
|
|
|
|
// GET /api/topics/{id}
|
|
func (h *TopicsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
if id == "" {
|
|
http.Error(w, "missing id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
t, err := h.store.GetByID(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.Error(w, "topic not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "get failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Visibility gate: anonymous can only see public; authenticated see all.
|
|
caller := auth.FromContext(r.Context())
|
|
if caller.Kind == "" && t.Visibility != models.VisibilityPublic {
|
|
http.Error(w, "not found", http.StatusNotFound) // 404 not 403 — hide existence
|
|
return
|
|
}
|
|
// Enrich with camps so an agent can locate their own allocation in one
|
|
// round-trip. Camps are 0 rows pre-signup_close, 3 rows after — small
|
|
// enough that inlining costs nothing. Arguments are deliberately NOT
|
|
// inlined (potentially large; agents who need the transcript should
|
|
// hit GET /api/topics/{id}/arguments via dialectic_list_arguments).
|
|
//
|
|
// Backward-compatible: existing callers reading the original Topic
|
|
// fields keep working; new callers can read `camps` alongside.
|
|
camps, cErr := h.camps.ListByTopic(r.Context(), id)
|
|
if cErr != nil {
|
|
camps = nil // best-effort; metadata still useful
|
|
}
|
|
// Marshal Topic into a map and add `camps` as a sibling field rather
|
|
// than wrapping under "topic" — that would break every existing
|
|
// consumer that reads e.g. response.title / response.status directly.
|
|
buf, _ := json.Marshal(t)
|
|
out := map[string]any{}
|
|
_ = json.Unmarshal(buf, &out)
|
|
out["camps"] = camps
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
type createTopicBody struct {
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
Visibility string `json:"visibility"` // default "private"
|
|
VerdictSchemaID string `json:"verdict_schema_id"` // default "free-form"
|
|
SignupOpenAt string `json:"signup_open_at"` // RFC3339
|
|
SignupCloseAt string `json:"signup_close_at"`
|
|
DebateStartAt string `json:"debate_start_at"`
|
|
DebateEndAt string `json:"debate_end_at"`
|
|
// Optional: per-topic announce-channel target. Both must be set
|
|
// (or both omitted = no broadcasts). Creator picks based on the
|
|
// debate's intended audience (different guilds may host different
|
|
// communities, different channels may serve different categories).
|
|
AnnounceGuildBaseURL string `json:"announce_guild_base_url,omitempty"`
|
|
AnnounceChannelID string `json:"announce_channel_id,omitempty"`
|
|
}
|
|
|
|
// POST /api/topics
|
|
//
|
|
// Allowed callers: agent or authenticated user. Anonymous rejected
|
|
// (the route wires the auth-required middleware).
|
|
func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
caller := auth.FromContext(r.Context())
|
|
if caller.Kind == "" {
|
|
http.Error(w, "auth required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var body createTopicBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Title == "" || body.Summary == "" {
|
|
http.Error(w, "title and summary required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if body.Visibility == "" {
|
|
body.Visibility = string(models.VisibilityPrivate)
|
|
}
|
|
if body.VerdictSchemaID == "" {
|
|
body.VerdictSchemaID = "free-form"
|
|
}
|
|
parsed, err := validateLifecycleTimes(body)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Announce target: both fields required or both empty; one-of-two
|
|
// is a config error caught here rather than silently treated as
|
|
// "no broadcast".
|
|
var aGuild, aChannel *string
|
|
if body.AnnounceGuildBaseURL != "" || body.AnnounceChannelID != "" {
|
|
if body.AnnounceGuildBaseURL == "" || body.AnnounceChannelID == "" {
|
|
http.Error(w, "announce_guild_base_url and announce_channel_id must both be set (or both empty for no broadcasts)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
g, c := body.AnnounceGuildBaseURL, body.AnnounceChannelID
|
|
aGuild, aChannel = &g, &c
|
|
}
|
|
|
|
created, err := h.store.Create(r.Context(), store.CreateTopicInput{
|
|
Title: body.Title,
|
|
Summary: body.Summary,
|
|
Visibility: models.Visibility(body.Visibility),
|
|
VerdictSchemaID: body.VerdictSchemaID,
|
|
SignupOpenAt: parsed[0],
|
|
SignupCloseAt: parsed[1],
|
|
DebateStartAt: parsed[2],
|
|
DebateEndAt: parsed[3],
|
|
CreatorUserID: caller.ID,
|
|
AnnounceGuildBaseURL: aGuild,
|
|
AnnounceChannelID: aChannel,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
// validateLifecycleTimes enforces:
|
|
//
|
|
// signup_open < signup_close <= debate_start < debate_end
|
|
//
|
|
// All four timestamps must be parsable as RFC3339; failure → 400.
|
|
// Returns the parsed times in order [signup_open, signup_close, debate_start, debate_end]
|
|
// so the caller can pass typed values to the store (MySQL TIMESTAMP doesn't
|
|
// accept ISO8601 strings directly; the driver handles time.Time properly).
|
|
func validateLifecycleTimes(b createTopicBody) ([4]time.Time, error) {
|
|
type p struct {
|
|
name string
|
|
raw string
|
|
}
|
|
parts := []p{
|
|
{"signup_open_at", b.SignupOpenAt},
|
|
{"signup_close_at", b.SignupCloseAt},
|
|
{"debate_start_at", b.DebateStartAt},
|
|
{"debate_end_at", b.DebateEndAt},
|
|
}
|
|
var parsed [4]time.Time
|
|
for i, x := range parts {
|
|
t, err := time.Parse(time.RFC3339, x.raw)
|
|
if err != nil {
|
|
return parsed, errors.New(x.name + ": must be RFC3339")
|
|
}
|
|
parsed[i] = t.UTC()
|
|
}
|
|
if !parsed[0].Before(parsed[1]) {
|
|
return parsed, errors.New("signup_open_at must be before signup_close_at")
|
|
}
|
|
if parsed[1].After(parsed[2]) {
|
|
return parsed, errors.New("signup_close_at must be <= debate_start_at")
|
|
}
|
|
if !parsed[2].Before(parsed[3]) {
|
|
return parsed, errors.New("debate_start_at must be before debate_end_at")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any
|
|
// authenticated user; Phase 4 will check the dialectic-admin role from JWT).
|
|
func (h *TopicsHandler) SetVisibility(w http.ResponseWriter, r *http.Request) {
|
|
caller := auth.FromContext(r.Context())
|
|
if caller.Kind == "" {
|
|
http.Error(w, "auth required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if caller.Kind == auth.CallerUser && !hasRole(caller, "dialectic-admin") {
|
|
http.Error(w, "dialectic-admin role required", http.StatusForbidden)
|
|
return
|
|
}
|
|
id := chi.URLParam(r, "id")
|
|
var body struct {
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, "bad body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
v := models.Visibility(body.Visibility)
|
|
if v != models.VisibilityPublic && v != models.VisibilityPrivate {
|
|
http.Error(w, "visibility must be public|private", http.StatusBadRequest)
|
|
return
|
|
}
|
|
t, err := h.store.SetVisibility(r.Context(), id, v, caller.ID)
|
|
if err != nil {
|
|
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, t)
|
|
}
|
|
|
|
func hasRole(c auth.Caller, role string) bool {
|
|
for _, r := range c.Roles {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("content-type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|