feat(phase3): threads backend + thread panel UI
This commit is contained in:
@@ -27,6 +27,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}", h.Get)
|
||||
r.Patch("/{channelID}", h.Update)
|
||||
r.Delete("/{channelID}", h.Delete)
|
||||
r.Post("/{channelID}/threads", h.CreateThread)
|
||||
r.Get("/{channelID}/threads", h.ListThreads)
|
||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||
h.registerOverrideRoutes(r)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -316,6 +316,34 @@ CREATE TABLE IF NOT EXISTS channel_overrides (
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
||||
|
||||
-- Threads: reuse channels table by adding parent_channel_id; thread messages are still messages scoped to the thread channel.
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS parent_channel_id UUID REFERENCES channels(id) ON DELETE CASCADE;
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_archive_duration INTEGER NOT NULL DEFAULT 1440;
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
||||
|
||||
-- Forum tags
|
||||
CREATE TABLE IF NOT EXISTS forum_tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
emoji VARCHAR(32),
|
||||
color VARCHAR(7),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (channel_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_tags_channel ON forum_tags(channel_id);
|
||||
|
||||
-- Thread tag assignments (for forum posts)
|
||||
CREATE TABLE IF NOT EXISTS channel_tags (
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES forum_tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (channel_id, tag_id)
|
||||
);
|
||||
|
||||
-- Message full-text search
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||
|
||||
Reference in New Issue
Block a user