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