Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.
Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
announce_channel_id VARCHAR(64) NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
topic creator opted out of broadcasts (announcer skips with log).
handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.
fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.
orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.
verdict.go: same per-topic target extraction at completion.
config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").
FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
247 lines
7.9 KiB
Go
247 lines
7.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
|
|
}
|
|
|
|
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"`
|
|
// 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)
|
|
}
|