Update to use MySQL Database
This commit is contained in:
13
CLAUDE.md
13
CLAUDE.md
@@ -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 |
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
18
go.mod
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user