bb5a56816b
Backend: - WebSocket gateway (hub, client, events) with fanout broadcast - Server CRUD handlers (create, list, get, update, delete) - Channel CRUD handlers (create, list, get, update, delete) - Message CRUD handlers (list with cursor pagination, create, update, delete) - cmd/migrate standalone migration CLI (up/down) - cmd/server wired to all handlers + WebSocket + static file serving Frontend: - Zustand stores: auth, server, channel, message, websocket - API client with fetch wrapper - Terminal-styled components: Layout, LoginForm, ChatArea, ChannelList, ServerBar, MemberList - React Router with login and main routes - Gruvbox dark palette throughout Ops: - Docker Compose with app service (multi-stage build) - Caddyfile with WebSocket upgrade support - Makefile for common tasks
319 lines
8.7 KiB
Go
319 lines
8.7 KiB
Go
package channel
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Handler holds the database dependency for channel CRUD operations.
|
|
type Handler struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewHandler creates a new channel Handler.
|
|
func NewHandler(db *sql.DB) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
// RegisterRoutes registers channel routes on the given chi.Router.
|
|
// Expects to be mounted under /servers/{serverID}/channels.
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
r.Post("/", h.Create)
|
|
r.Get("/", h.List)
|
|
r.Get("/{channelID}", h.Get)
|
|
r.Patch("/{channelID}", h.Update)
|
|
r.Delete("/{channelID}", h.Delete)
|
|
}
|
|
|
|
// Channel is the JSON representation of a channel.
|
|
type Channel struct {
|
|
ID string `json:"id"`
|
|
ServerID string `json:"server_id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Category string `json:"category"`
|
|
Position int `json:"position"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type createChannelRequest struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Category string `json:"category"`
|
|
Position *int `json:"position,omitempty"`
|
|
}
|
|
|
|
type updateChannelRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Type *string `json:"type,omitempty"`
|
|
Category *string `json:"category,omitempty"`
|
|
Position *int `json:"position,omitempty"`
|
|
}
|
|
|
|
// Create handles POST / — creates a new channel in a server.
|
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
serverID := chi.URLParam(r, "serverID")
|
|
if _, err := uuid.Parse(serverID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid server id")
|
|
return
|
|
}
|
|
|
|
// Verify membership
|
|
if !h.isMember(r, userID, serverID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
var req createChannelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
req.Type = "text"
|
|
}
|
|
if req.Type != "text" && req.Type != "voice" {
|
|
writeError(w, http.StatusBadRequest, "type must be text or voice")
|
|
return
|
|
}
|
|
if req.Category == "" {
|
|
req.Category = "General"
|
|
}
|
|
|
|
position := 0
|
|
if req.Position != nil {
|
|
position = *req.Position
|
|
}
|
|
|
|
var channelID string
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
INSERT INTO channels (server_id, name, type, category, position)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, serverID, req.Name, req.Type, req.Category, position).Scan(&channelID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create channel")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"id": channelID})
|
|
}
|
|
|
|
// List handles GET / — returns all channels in a server.
|
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
serverID := chi.URLParam(r, "serverID")
|
|
if _, err := uuid.Parse(serverID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid server id")
|
|
return
|
|
}
|
|
|
|
if !h.isMember(r, userID, serverID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
rows, err := h.db.QueryContext(r.Context(), `
|
|
SELECT id, server_id, name, type, category, position, created_at
|
|
FROM channels
|
|
WHERE server_id = $1
|
|
ORDER BY position ASC, created_at ASC
|
|
`, serverID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list channels")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
channels := []Channel{}
|
|
for rows.Next() {
|
|
var c Channel
|
|
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server error")
|
|
return
|
|
}
|
|
channels = append(channels, c)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server error")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(channels)
|
|
}
|
|
|
|
// Get handles GET /{channelID} — returns a single channel.
|
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
serverID := chi.URLParam(r, "serverID")
|
|
channelID := chi.URLParam(r, "channelID")
|
|
if _, err := uuid.Parse(channelID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
return
|
|
}
|
|
|
|
if !h.isMember(r, userID, serverID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
var c Channel
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT id, server_id, name, type, category, position, created_at
|
|
FROM channels
|
|
WHERE id = $1 AND server_id = $2
|
|
`, channelID, serverID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "channel not found")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(c)
|
|
}
|
|
|
|
// Update handles PATCH /{channelID} — updates a channel's properties.
|
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
serverID := chi.URLParam(r, "serverID")
|
|
channelID := chi.URLParam(r, "channelID")
|
|
if _, err := uuid.Parse(channelID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
return
|
|
}
|
|
|
|
if !h.isMember(r, userID, serverID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
var req updateChannelRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
// Validate type if provided
|
|
if req.Type != nil && *req.Type != "text" && *req.Type != "voice" {
|
|
writeError(w, http.StatusBadRequest, "type must be text or voice")
|
|
return
|
|
}
|
|
|
|
result, err := h.db.ExecContext(r.Context(), `
|
|
UPDATE channels SET
|
|
name = COALESCE($1, name),
|
|
type = COALESCE($2, type),
|
|
category = COALESCE($3, category),
|
|
position = COALESCE($4, position)
|
|
WHERE id = $5 AND server_id = $6
|
|
`, req.Name, req.Type, req.Category, req.Position, channelID, serverID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update channel")
|
|
return
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "channel not found")
|
|
return
|
|
}
|
|
|
|
var c Channel
|
|
h.db.QueryRowContext(r.Context(), `
|
|
SELECT id, server_id, name, type, category, position, created_at
|
|
FROM channels WHERE id = $1
|
|
`, channelID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(c)
|
|
}
|
|
|
|
// Delete handles DELETE /{channelID} — deletes a channel.
|
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
serverID := chi.URLParam(r, "serverID")
|
|
channelID := chi.URLParam(r, "channelID")
|
|
if _, err := uuid.Parse(channelID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
return
|
|
}
|
|
|
|
if !h.isMember(r, userID, serverID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
result, err := h.db.ExecContext(r.Context(),
|
|
`DELETE FROM channels WHERE id = $1 AND server_id = $2`,
|
|
channelID, serverID,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete channel")
|
|
return
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "channel not found")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// isMember checks if a user is a member of the given server.
|
|
func (h *Handler) isMember(r *http.Request, userID, serverID string) bool {
|
|
var exists bool
|
|
h.db.QueryRowContext(r.Context(),
|
|
`SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)`,
|
|
userID, serverID,
|
|
).Scan(&exists)
|
|
return exists
|
|
}
|
|
|
|
// writeError sends a JSON error response.
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
|
}
|