Phase 1 MVP: gateway, CRUD handlers, frontend components
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
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user