From 25fd4a8be796afe16a88e96b69bebef7963da026 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 5 Mar 2026 14:24:04 -0400 Subject: [PATCH] Update to use MySQL Database --- CLAUDE.md | 13 +++++- cmd/server/main.go | 9 ++++- docker-compose.yml | 30 ++++++++++++-- go.mod | 18 ++------- internal/db/db.go | 7 +--- internal/db/schema.go | 93 ++++++++++++++++++++++++------------------- 6 files changed, 104 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e7f0932..c260fbf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ For a single Go package test: `go test ./internal/volunteer/...` ## Architecture ### Go Backend + Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package: | Package | Responsibility | @@ -43,23 +44,31 @@ Domain-based packaging under `internal/` — each domain owns its models, store All API routes are prefixed `/api/v1`. The Go binary serves the compiled React app from `STATIC_DIR` (default `./web/dist`) for all non-API routes. ### React Frontend + Standard CRA layout in `web/src/`: + - `api.ts` — typed fetch wrapper; reads JWT from `localStorage`, prefixes `BASE = '/api/v1'` - `auth.tsx` — `AuthProvider` context; decodes the JWT payload to expose `role` and `volunteerID` - `pages/` — one file per route (`Dashboard`, `Schedules`, `TimeOff`, `Volunteers`, `Login`) - `App.tsx` — `BrowserRouter` with a `ProtectedLayout` that redirects to `/login` when no token is present; admin-only nav links gated on `role === 'admin'` ### Data Storage -SQLite via `modernc.org/sqlite` (pure Go, no CGO). Schema is defined inline in `internal/db/schema.go` and applied via `CREATE TABLE IF NOT EXISTS` on every startup. No migration framework — additive changes are safe; destructive changes require manual handling. + +MySQL 8.0 via `github.com/go-sql-driver/mysql`. Schema is defined inline in `internal/db/schema.go` and applied via `CREATE TABLE IF NOT EXISTS` on every startup. No migration framework — additive changes are safe; destructive changes require manual handling. ### Auth + HS256 JWT signed with `JWT_SECRET`. Token payload: `{ volunteer_id, role, exp }`. Role is either `volunteer` or `admin`. The `RequireAdmin` middleware enforces admin-only routes server-side. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `DATABASE_DSN` | `walkies.db` | SQLite file path | +| `DB_HOST` | `localhost` | MySQL host | +| `DB_PORT` | `3306` | MySQL port | +| `DB_USER` | `root` | MySQL username | +| `DB_PASSWORD` | `` | MySQL password | +| `DB_NAME` | `walkies` | MySQL database name | | `JWT_SECRET` | `change-me-in-production` | HMAC signing key | | `STATIC_DIR` | `./web/dist` | Path to compiled React app | | `PORT` | `8080` | HTTP listen port | diff --git a/cmd/server/main.go b/cmd/server/main.go index acd2cde..0b91e5c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,7 +11,14 @@ import ( ) func main() { - dsn := getenv("DATABASE_DSN", "walkies.db") + // Build MySQL DSN from environment variables + dbHost := getenv("DB_HOST", "localhost") + dbPort := getenv("DB_PORT", "3306") + dbUser := getenv("DB_USER", "root") + dbPassword := getenv("DB_PASSWORD", "") + dbName := getenv("DB_NAME", "walkies") + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", dbUser, dbPassword, dbHost, dbPort, dbName) + jwtSecret := getenv("JWT_SECRET", "change-me-in-production") staticDir := getenv("STATIC_DIR", "./web/dist") port := getenv("PORT", "8080") diff --git a/docker-compose.yml b/docker-compose.yml index 8c681c8..f4723b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,37 @@ services: + mysql: + image: mysql:8.0 + container_name: walkies-mysql + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: walkies + MYSQL_USER: walkies + MYSQL_PASSWORD: walkies-password + ports: + - "3306:3306" + volumes: + - walkies-mysql-data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + walkies: build: . ports: - "8080:8080" - volumes: - - walkies-data:/app/data + depends_on: + mysql: + condition: service_healthy environment: - DATABASE_DSN: /app/data/walkies.db + DB_HOST: mysql + DB_PORT: "3306" + DB_USER: walkies + DB_PASSWORD: walkies-password + DB_NAME: walkies JWT_SECRET: change-me-in-production PORT: "8080" STATIC_DIR: /app/web/dist volumes: - walkies-data: + walkies-mysql-data: diff --git a/go.mod b/go.mod index 8e721b9..16f20fb 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,8 @@ module git.unsupervised.ca/walkies go 1.25.0 require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.41.0 // indirect - modernc.org/libc v1.67.6 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.46.1 // indirect + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-sql-driver/mysql v1.8.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.48.0 ) diff --git a/internal/db/db.go b/internal/db/db.go index 6f3525f..cf73d99 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -4,20 +4,17 @@ import ( "database/sql" "fmt" - _ "modernc.org/sqlite" + _ "github.com/go-sql-driver/mysql" ) func Open(dsn string) (*sql.DB, error) { - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open("mysql", dsn) if err != nil { return nil, fmt.Errorf("open db: %w", err) } if err := db.Ping(); err != nil { return nil, fmt.Errorf("ping db: %w", err) } - if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`); err != nil { - return nil, fmt.Errorf("pragma: %w", err) - } return db, nil } diff --git a/internal/db/schema.go b/internal/db/schema.go index 03b5929..ac7e6bf 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -2,54 +2,67 @@ package db const schema = ` CREATE TABLE IF NOT EXISTS volunteers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'volunteer', -- 'admin' | 'volunteer' - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer', + active TINYINT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS schedules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), - title TEXT NOT NULL, - starts_at TEXT NOT NULL, - ends_at TEXT NOT NULL, + id INT AUTO_INCREMENT PRIMARY KEY, + volunteer_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, notes TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + INDEX idx_volunteer_id (volunteer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS time_off_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), - starts_at TEXT NOT NULL, - ends_at TEXT NOT NULL, + id INT AUTO_INCREMENT PRIMARY KEY, + volunteer_id INT NOT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, reason TEXT, - status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected' - reviewed_by INTEGER REFERENCES volunteers(id), - reviewed_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); + status VARCHAR(50) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected', + reviewed_by INT, + reviewed_at DATETIME, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + FOREIGN KEY (reviewed_by) REFERENCES volunteers(id) ON DELETE SET NULL, + INDEX idx_volunteer_id (volunteer_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS checkins ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), - schedule_id INTEGER REFERENCES schedules(id), - checked_in_at TEXT NOT NULL DEFAULT (datetime('now')), - checked_out_at TEXT, - notes TEXT -); + id INT AUTO_INCREMENT PRIMARY KEY, + volunteer_id INT NOT NULL, + schedule_id INT, + checked_in_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + checked_out_at TIMESTAMP NULL, + notes TEXT, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE SET NULL, + INDEX idx_volunteer_id (volunteer_id), + INDEX idx_schedule_id (schedule_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), - message TEXT NOT NULL, - read INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); + id INT AUTO_INCREMENT PRIMARY KEY, + volunteer_id INT NOT NULL, + message TEXT NOT NULL, + read TINYINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + INDEX idx_volunteer_id (volunteer_id), + INDEX idx_read (read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `