package channel import ( "database/sql" "encoding/json" "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) type createThreadRequest struct { Name string `json:"name"` MessageID *string `json:"message_id,omitempty"` TagIDs []string `json:"tag_ids,omitempty"` } type threadResponse struct { ID string `json:"id"` ServerID string `json:"server_id"` ParentChannelID string `json:"parent_channel_id"` Name string `json:"name"` Type string `json:"type"` Category *string `json:"category"` Position int `json:"position"` ArchivedAt *string `json:"archived_at"` AutoArchiveDuration int `json:"auto_archive_duration"` MessageCount int `json:"message_count"` LastMessageAt *string `json:"last_message_at"` CreatedAt string `json:"created_at"` } func (h *Handler) registerThreadRoutes(r chi.Router) { r.Post("/{channelID}/threads", h.CreateThread) r.Get("/{channelID}/threads", h.ListThreads) r.Patch("/threads/{threadID}", h.UpdateThread) } func scanThread(row interface{ Scan(dest ...any) error }) (threadResponse, error) { var t threadResponse var archivedAt, lastMsgAt, category sql.NullString err := row.Scan(&t.ID, &t.ServerID, &t.ParentChannelID, &t.Name, &t.Type, &category, &t.Position, &archivedAt, &t.AutoArchiveDuration, &t.MessageCount, &lastMsgAt, &t.CreatedAt) if err != nil { return t, err } if archivedAt.Valid { t.ArchivedAt = &archivedAt.String } if lastMsgAt.Valid { t.LastMessageAt = &lastMsgAt.String } if category.Valid { t.Category = &category.String } return t, nil } func (h *Handler) CreateThread(w http.ResponseWriter, r *http.Request) { parentID := chi.URLParam(r, "channelID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } serverID, err := h.serverIDForChannel(r.Context(), parentID) if err != nil { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil || !member { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.SEND_MESSAGES) if err != nil || !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } var req createThreadRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } // ponytail: reuse channels table with parent_channel_id set. Messages in the thread channel are normal messages. thread, err := scanThread(h.db.QueryRowContext(r.Context(), ` INSERT INTO channels (server_id, name, type, category, position, parent_channel_id, auto_archive_duration) VALUES ($1, $2, 'thread', 'threads', 0, $3, 1440) RETURNING id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at `, serverID, req.Name, parentID)) if err != nil { http.Error(w, `{"error":"failed to create thread"}`, http.StatusInternalServerError) return } // Attach optional tags for forum posts. if len(req.TagIDs) > 0 { // ponytail: ignore tag errors; best-effort assignment for _, tagID := range req.TagIDs { _, _ = h.db.ExecContext(r.Context(), ` INSERT INTO channel_tags (channel_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING `, thread.ID, tagID) } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(thread) } func (h *Handler) ListThreads(w http.ResponseWriter, r *http.Request) { parentID := chi.URLParam(r, "channelID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } serverID, err := h.serverIDForChannel(r.Context(), parentID) if err != nil { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil || !member { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } rows, err := h.db.QueryContext(r.Context(), ` SELECT id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at FROM channels WHERE parent_channel_id = $1 AND server_id = $2 AND type = 'thread' AND archived_at IS NULL ORDER BY last_message_at DESC NULLS LAST, created_at DESC `, parentID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() threads := make([]threadResponse, 0) for rows.Next() { t, err := scanThread(rows) if err != nil { continue } threads = append(threads, t) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(threads) } type updateThreadRequest struct { Archived *bool `json:"archived"` } func (h *Handler) UpdateThread(w http.ResponseWriter, r *http.Request) { threadID := chi.URLParam(r, "threadID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } var serverID string err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1 AND type = 'thread'`, threadID).Scan(&serverID) if err != nil { http.Error(w, `{"error":"thread not found"}`, http.StatusNotFound) return } var req updateThreadRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil || !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } var archivedAt interface{} if req.Archived != nil && *req.Archived { archivedAt = "NOW()" } else { archivedAt = nil } thread, err := scanThread(h.db.QueryRowContext(r.Context(), ` UPDATE channels SET archived_at = COALESCE($1, archived_at) WHERE id = $2 RETURNING id, server_id, parent_channel_id, name, type, category, position, archived_at, auto_archive_duration, message_count, last_message_at, created_at `, archivedAt, threadID)) if err != nil { http.Error(w, `{"error":"failed to update thread"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(thread) }