Update to use MySQL Database

This commit is contained in:
2026-03-05 14:24:04 -04:00
parent 9ecf919d68
commit 25fd4a8be7
6 changed files with 104 additions and 66 deletions

View File

@@ -23,6 +23,7 @@ For a single Go package test: `go test ./internal/volunteer/...`
## Architecture ## Architecture
### Go Backend ### Go Backend
Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package: Domain-based packaging under `internal/` — each domain owns its models, store (DB queries), and HTTP handler in a single package:
| Package | Responsibility | | 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. 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 ### React Frontend
Standard CRA layout in `web/src/`: Standard CRA layout in `web/src/`:
- `api.ts` — typed fetch wrapper; reads JWT from `localStorage`, prefixes `BASE = '/api/v1'` - `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` - `auth.tsx``AuthProvider` context; decodes the JWT payload to expose `role` and `volunteerID`
- `pages/` — one file per route (`Dashboard`, `Schedules`, `TimeOff`, `Volunteers`, `Login`) - `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'` - `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 ### 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 ### 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. 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 ## Environment Variables
| Variable | Default | Description | | 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 | | `JWT_SECRET` | `change-me-in-production` | HMAC signing key |
| `STATIC_DIR` | `./web/dist` | Path to compiled React app | | `STATIC_DIR` | `./web/dist` | Path to compiled React app |
| `PORT` | `8080` | HTTP listen port | | `PORT` | `8080` | HTTP listen port |

View File

@@ -11,7 +11,14 @@ import (
) )
func main() { 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") jwtSecret := getenv("JWT_SECRET", "change-me-in-production")
staticDir := getenv("STATIC_DIR", "./web/dist") staticDir := getenv("STATIC_DIR", "./web/dist")
port := getenv("PORT", "8080") port := getenv("PORT", "8080")

View File

@@ -1,15 +1,37 @@
services: 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: walkies:
build: . build: .
ports: ports:
- "8080:8080" - "8080:8080"
volumes: depends_on:
- walkies-data:/app/data mysql:
condition: service_healthy
environment: 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 JWT_SECRET: change-me-in-production
PORT: "8080" PORT: "8080"
STATIC_DIR: /app/web/dist STATIC_DIR: /app/web/dist
volumes: volumes:
walkies-data: walkies-mysql-data:

18
go.mod
View File

@@ -3,18 +3,8 @@ module git.unsupervised.ca/walkies
go 1.25.0 go 1.25.0
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 // indirect golang.org/x/crypto v0.48.0
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
) )

View File

@@ -4,20 +4,17 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
_ "modernc.org/sqlite" _ "github.com/go-sql-driver/mysql"
) )
func Open(dsn string) (*sql.DB, error) { func Open(dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("mysql", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open db: %w", err) return nil, fmt.Errorf("open db: %w", err)
} }
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err) 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 return db, nil
} }

View File

@@ -2,54 +2,67 @@ package db
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS volunteers ( CREATE TABLE IF NOT EXISTS volunteers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
name TEXT NOT NULL, name VARCHAR(255) NOT NULL,
email TEXT NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE,
password TEXT NOT NULL, password VARCHAR(255) NOT NULL,
role TEXT NOT NULL DEFAULT 'volunteer', -- 'admin' | 'volunteer' role VARCHAR(50) NOT NULL DEFAULT 'volunteer' COMMENT 'admin or volunteer',
active INTEGER NOT NULL DEFAULT 1, active TINYINT NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), volunteer_id INT NOT NULL,
title TEXT NOT NULL, title VARCHAR(255) NOT NULL,
starts_at TEXT NOT NULL, starts_at DATETIME NOT NULL,
ends_at TEXT NOT NULL, ends_at DATETIME NOT NULL,
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( CREATE TABLE IF NOT EXISTS time_off_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), volunteer_id INT NOT NULL,
starts_at TEXT NOT NULL, starts_at DATETIME NOT NULL,
ends_at TEXT NOT NULL, ends_at DATETIME NOT NULL,
reason TEXT, reason TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'rejected' status VARCHAR(50) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected',
reviewed_by INTEGER REFERENCES volunteers(id), reviewed_by INT,
reviewed_at TEXT, reviewed_at DATETIME,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), volunteer_id INT NOT NULL,
schedule_id INTEGER REFERENCES schedules(id), schedule_id INT,
checked_in_at TEXT NOT NULL DEFAULT (datetime('now')), checked_in_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
checked_out_at TEXT, checked_out_at TIMESTAMP NULL,
notes TEXT 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 ( CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INTEGER NOT NULL REFERENCES volunteers(id), volunteer_id INT NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0, read TINYINT NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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;
` `