package handlers import ( "encoding/json" "errors" "net/http" "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 SignupsHandler struct { topics *store.TopicStore signups *store.SignupStore } func NewSignupsHandler(t *store.TopicStore, s *store.SignupStore) *SignupsHandler { return &SignupsHandler{topics: t, signups: s} } type signupBody struct { WillingCamps []models.Camp `json:"willing_camps"` PreValidated bool `json:"pre_validated"` } // POST /api/topics/{id}/signups // // Agent self-enrollment. Only CallerAgent is allowed — browsers can't // sign up on behalf of an agent (would defeat the on_call pre-check // that the plugin does before calling this endpoint). // // Body: { willing_camps: [pro|con|judge ...], pre_validated: bool } // // pre_validated is the agent's plugin's claim that it verified the // agent has an on_call HF slot covering [debate_start_at, debate_end_at]. // Backend trusts but logs — Phase N may add server-side verification. // // Topic must be in status `signup_open`. Outside that window → 409. func (h *SignupsHandler) Create(w http.ResponseWriter, r *http.Request) { caller := auth.FromContext(r.Context()) if caller.Kind != auth.CallerAgent { http.Error(w, "signup is agent-only", http.StatusForbidden) return } topicID := chi.URLParam(r, "id") t, 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 t.Status != models.TopicStatusSignupOpen { http.Error(w, "signup window not open (status="+string(t.Status)+")", http.StatusConflict) return } now := time.Now() if now.Before(t.SignupOpenAt) { http.Error(w, "signup not yet open", http.StatusConflict) return } if now.After(t.SignupCloseAt) { http.Error(w, "signup closed", http.StatusConflict) return } var body signupBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } if len(body.WillingCamps) == 0 { http.Error(w, "willing_camps required", http.StatusBadRequest) return } // Dedup the camps so a buggy plugin can't insert duplicates. camps := dedupCamps(body.WillingCamps) view, err := h.signups.Upsert(r.Context(), store.UpsertSignupInput{ TopicID: topicID, AgentID: caller.ID, WillingCamps: camps, PreValidated: body.PreValidated, }) if err != nil { http.Error(w, "upsert failed: "+err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusCreated, view) } // GET /api/topics/{id}/signups — list all signups for a topic. // // Visible to topic creator + admins + agents. Public anonymous see // nothing (avoid leaking who-signed-up for private topics). func (h *SignupsHandler) List(w http.ResponseWriter, r *http.Request) { caller := auth.FromContext(r.Context()) if caller.Kind == "" { http.Error(w, "auth required", http.StatusUnauthorized) return } topicID := chi.URLParam(r, "id") if _, err := h.topics.GetByID(r.Context(), topicID); err != nil { http.Error(w, "topic not found", http.StatusNotFound) return } rows, err := h.signups.ListByTopic(r.Context(), topicID) if err != nil { http.Error(w, "list failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{"signups": rows, "count": len(rows)}) } func dedupCamps(in []models.Camp) []models.Camp { seen := map[models.Camp]struct{}{} out := make([]models.Camp, 0, len(in)) for _, c := range in { if _, ok := seen[c]; ok { continue } seen[c] = struct{}{} out = append(out, c) } return out }