feat(phase3): forum channels + tags + card UI

This commit is contained in:
2026-06-30 10:44:52 -04:00
parent f3f03df710
commit 368172e3d6
12 changed files with 407 additions and 87 deletions
+156
View File
@@ -0,0 +1,156 @@
package channel
import (
"database/sql"
"encoding/json"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type forumTag struct {
ID string `json:"id"`
Name string `json:"name"`
Emoji *string `json:"emoji"`
Color *string `json:"color"`
TagID string `json:"tag_id"`
}
type forumPost struct {
threadResponse
TagIDs []string `json:"tag_ids"`
}
func (h *Handler) registerForumRoutes(r chi.Router) {
r.Get("/{channelID}/forum-tags", h.ListForumTags)
r.Post("/{channelID}/forum-tags", h.CreateForumTag)
r.Delete("/forum-tags/{tagID}", h.DeleteForumTag)
}
// CreateForumTag creates a tag for a forum channel.
func (h *Handler) CreateForumTag(w http.ResponseWriter, r *http.Request) {
channelID := 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(), channelID)
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
}
var req struct {
Name string `json:"name"`
Emoji *string `json:"emoji"`
Color *string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var tag forumTag
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO forum_tags (channel_id, name, emoji, color)
VALUES ($1, $2, $3, $4)
RETURNING id, name, emoji, color
`, channelID, req.Name, req.Emoji, req.Color).Scan(&tag.ID, &tag.Name, &tag.Emoji, &tag.Color)
if err != nil {
http.Error(w, `{"error":"failed to create tag"}`, http.StatusInternalServerError)
return
}
tag.TagID = tag.ID
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(tag)
}
// ListForumTags lists tags for a forum channel.
func (h *Handler) ListForumTags(w http.ResponseWriter, r *http.Request) {
channelID := 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(), channelID)
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, name, emoji, color FROM forum_tags WHERE channel_id = $1 ORDER BY name
`, channelID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
tags := make([]forumTag, 0)
for rows.Next() {
var t forumTag
var emoji, color sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &emoji, &color); err != nil {
continue
}
if emoji.Valid {
t.Emoji = &emoji.String
}
if color.Valid {
t.Color = &color.String
}
t.TagID = t.ID
tags = append(tags, t)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tags)
}
// DeleteForumTag deletes a forum tag.
func (h *Handler) DeleteForumTag(w http.ResponseWriter, r *http.Request) {
tagID := chi.URLParam(r, "tagID")
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var channelID string
err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM forum_tags WHERE id = $1`, tagID).Scan(&channelID)
if err != nil {
http.Error(w, `{"error":"tag not found"}`, http.StatusNotFound)
return
}
serverID, err := h.serverIDForChannel(r.Context(), channelID)
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
}
_, err = h.db.ExecContext(r.Context(), `DELETE FROM forum_tags WHERE id = $1`, tagID)
if err != nil {
http.Error(w, `{"error":"failed to delete tag"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -30,6 +30,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/{channelID}/threads", h.CreateThread)
r.Get("/{channelID}/threads", h.ListThreads)
r.Patch("/threads/{threadID}", h.UpdateThread)
h.registerForumRoutes(r)
h.registerOverrideRoutes(r)
}
+13 -2
View File
@@ -11,8 +11,9 @@ import (
)
type createThreadRequest struct {
Name string `json:"name"`
MessageID *string `json:"message_id,omitempty"`
Name string `json:"name"`
MessageID *string `json:"message_id,omitempty"`
TagIDs []string `json:"tag_ids,omitempty"`
}
type threadResponse struct {
@@ -95,6 +96,16 @@ func (h *Handler) CreateThread(w http.ResponseWriter, r *http.Request) {
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)