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,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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user