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:
2026-06-26 14:47:29 -04:00
parent aa7854aee2
commit bb5a56816b
32 changed files with 2310 additions and 339 deletions
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
direction := flag.String("direction", "up", "migration direction: up or down")
flag.Parse()
cfg := config.Load()
db, err := sql.Open("pgx", cfg.DatabaseDSN())
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
switch *direction {
case "up":
fmt.Println("running migrations up...")
if err := runUp(db); err != nil {
log.Fatalf("migration failed: %v", err)
}
fmt.Println("migrations complete")
case "down":
fmt.Println("running migrations down...")
if err := runDown(db); err != nil {
log.Fatalf("migration failed: %v", err)
}
fmt.Println("migrations complete")
default:
fmt.Fprintf(os.Stderr, "unknown direction: %s\n", *direction)
os.Exit(1)
}
}
const upSQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
display_name VARCHAR(32),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255),
avatar TEXT,
status VARCHAR(16) NOT NULL DEFAULT 'offline',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS servers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
icon TEXT,
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
type VARCHAR(16) NOT NULL DEFAULT 'text',
category VARCHAR(100) NOT NULL DEFAULT 'general',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (server_id, name)
);
CREATE TABLE IF NOT EXISTS members (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, server_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
color VARCHAR(7),
permissions BIGINT NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS member_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id);
`
const downSQL = `
DROP TABLE IF EXISTS member_roles;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS channels;
DROP TABLE IF EXISTS servers;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
`
func runUp(db *sql.DB) error {
_, err := db.Exec(upSQL)
return err
}
func runDown(db *sql.DB) error {
_, err := db.Exec(downSQL)
return err
}
+65 -7
View File
@@ -7,10 +7,15 @@ import (
"os"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
chimw "github.com/go-chi/chi/v5/middleware"
)
func main() {
@@ -30,20 +35,73 @@ func main() {
os.Exit(1)
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
// Session store for middleware
sessionStore := auth.NewSessionStore(database.DB, cfg)
// WebSocket hub
hub := gateway.NewHub(logger)
go hub.Run()
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
r.Route("/api/v1/auth", func(r chi.Router) {
auth.NewHandler(database.DB, cfg).RegisterRoutes(r)
// WebSocket endpoint
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
gateway.ServeWS(database.DB, hub, logger, w, r)
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Auth (public)
r.Route("/auth", func(r chi.Router) {
auth.NewHandler(database.DB, cfg).RegisterRoutes(r)
})
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Session(sessionStore, cfg))
r.Use(middleware.RequireAuth)
// Servers
r.Route("/servers", func(r chi.Router) {
server.NewHandler(database.DB).RegisterRoutes(r)
// Channels (nested under servers)
r.Route("/{serverID}/channels", func(r chi.Router) {
channel.NewHandler(database.DB).RegisterRoutes(r)
})
})
// Messages (under channels)
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
message.NewHandler(database.DB, hub).RegisterRoutes(r)
})
})
})
// Static file serving for production (SPA)
staticDir := "/srv/web"
if _, err := os.Stat(staticDir); err == nil {
fileServer := http.FileServer(http.Dir(staticDir))
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
path := staticDir + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {
http.ServeFile(w, r, staticDir+"/index.html")
return
}
fileServer.ServeHTTP(w, r)
})
}
addr := fmt.Sprintf(":%s", cfg.Port)
logger.Info("starting server", "addr", addr)
if err := http.ListenAndServe(addr, r); err != nil {