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 } func NewTopicsHandler(s *store.TopicStore) *TopicsHandler { return &TopicsHandler{store: s} } // 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 } writeJSON(w, http.StatusOK, t) } 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"` } // 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" } if err := validateLifecycleTimes(body); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } created, err := h.store.Create(r.Context(), store.CreateTopicInput{ Title: body.Title, Summary: body.Summary, Visibility: models.Visibility(body.Visibility), VerdictSchemaID: body.VerdictSchemaID, SignupOpenAt: body.SignupOpenAt, SignupCloseAt: body.SignupCloseAt, DebateStartAt: body.DebateStartAt, DebateEndAt: body.DebateEndAt, CreatorUserID: caller.ID, }) 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. func validateLifecycleTimes(b createTopicBody) 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}, } parsed := make([]time.Time, 4) for i, x := range parts { t, err := time.Parse(time.RFC3339, x.raw) if err != nil { return errors.New(x.name + ": must be RFC3339") } parsed[i] = t } if !parsed[0].Before(parsed[1]) { return errors.New("signup_open_at must be before signup_close_at") } if parsed[1].After(parsed[2]) { return errors.New("signup_close_at must be <= debate_start_at") } if !parsed[2].Before(parsed[3]) { return errors.New("debate_start_at must be before debate_end_at") } return 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) }