diff --git a/Makefile b/Makefile index c6a07bc..9003174 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ .PHONY: generate build run test test-integration test-integration-verbose test-all lint swagger docker docker-down ci benchmark \ - migrate-create migrate-up migrate-down migrate-down-all migrate-down-one migrate-version migrate-force \ - migrate-up-docker migrate-down-docker migrate-down-all-docker migrate-down-one-docker migrate-version-docker migrate-force-docker + migrate-create migrate-up migrate-down migrate-version MIGRATE ?= migrate MIGRATIONS_DIR ?= migrations @@ -24,64 +23,20 @@ migrate-create: $(MIGRATE) create -ext sql -dir $(MIGRATIONS_DIR) $(name) migrate-up: - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - $(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" up - -migrate-down: - @$(MAKE) migrate-down-all - -migrate-down-all: - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - $(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" down -all - -migrate-down-one: - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - $(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" down 1 - -migrate-version: - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - $(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" version - -migrate-force: - @test -n "$(version)" || (echo "Usage: make migrate-force version=" && exit 1) - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - $(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" force $(version) - -# Database migrations via Docker (uses Compose network, works with DATABASE_URL=db:5432) -migrate-up-docker: @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" up -migrate-down-docker: - @$(MAKE) migrate-down-all-docker - -migrate-down-all-docker: - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" down -all - -migrate-down-one-docker: +migrate-down: @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" down 1 -migrate-version-docker: +migrate-version: @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" version -migrate-force-docker: - @test -n "$(version)" || (echo "Usage: make migrate-force-docker version=" && exit 1) - @db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \ - test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \ - docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" force $(version) - # Build build: generate go build -o bin/capy-server ./cmd/server diff --git a/README.md b/README.md index 4f04947..1ae1f52 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,12 @@ make docker ### 3. Run Migrations & Generate Code ```bash -# If your DB is running via docker-compose on your host machine, override host: -# export MIGRATE_DATABASE_URL=postgres://capy:devpassword@localhost:5432/capy_db?sslmode=disable or use make migrate-up-docker make migrate-up make generate ``` +`make migrate-up` runs all pending migrations in Docker on the Compose network by default. `make migrate-down` rolls back exactly one migration. `make migrate-version` shows the current version. + Create a new migration: ```bash make migrate-create name=add_event_capacity @@ -100,6 +100,8 @@ make run # Health check: http://localhost:8080/health ``` +The API applies pending migrations automatically on startup before serving requests. + ## Testing ### Unit Tests diff --git a/cmd/server/main.go b/cmd/server/main.go index f428e69..12d91bb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "syscall" "time" + swaggerdocs "github.com/capyrpi/api/docs/swagger" "github.com/capyrpi/api/internal/config" "github.com/capyrpi/api/internal/database" "github.com/capyrpi/api/internal/handler" @@ -50,8 +51,17 @@ func main() { slog.Info("starting server", "env", cfg.Env) + configureSwagger(cfg) + ctx := context.Background() + if err := database.RunMigrations(ctx, cfg.Database.URL, cfg.Database.MigrationsPath); err != nil { + slog.Error("failed to run migrations", "error", err, "path", cfg.Database.MigrationsPath) + os.Exit(1) + } + + slog.Info("migrations applied", "path", cfg.Database.MigrationsPath) + // Connect to database pool, err := database.NewPool(ctx, cfg.Database.URL) if err != nil { @@ -104,3 +114,12 @@ func main() { slog.Info("server stopped") } + +func configureSwagger(cfg *config.Config) { + if cfg.Env != "development" { + return + } + + swaggerdocs.SwaggerInfo.Host = "localhost:" + cfg.Server.Port + swaggerdocs.SwaggerInfo.Schemes = []string{"http"} +} diff --git a/docker-compose.yml b/docker-compose.yml index 9f5f0a7..6594f6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 diff --git a/migrations/20260316192946_initial_schema.down.sql b/migrations/20260316192946_initial_schema.down.sql new file mode 100644 index 0000000..618569a --- /dev/null +++ b/migrations/20260316192946_initial_schema.down.sql @@ -0,0 +1,16 @@ +DROP TRIGGER IF EXISTS update_events_modtime ON events; +DROP TRIGGER IF EXISTS update_orgs_modtime ON organizations; +DROP TRIGGER IF EXISTS update_users_modtime ON users; + +DROP INDEX IF EXISTS idx_bot_tokens_active; + +DROP TABLE IF EXISTS bot_tokens; +DROP TABLE IF EXISTS event_registrations; +DROP TABLE IF EXISTS event_hosting; +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS org_members; +DROP TABLE IF EXISTS organizations; +DROP TABLE IF EXISTS users; + +DROP FUNCTION IF EXISTS update_modified_column(); +DROP TYPE IF EXISTS user_role; diff --git a/migrations/20260316192946_initial_schema.up.sql b/migrations/20260316192946_initial_schema.up.sql new file mode 100644 index 0000000..aff59d4 --- /dev/null +++ b/migrations/20260316192946_initial_schema.up.sql @@ -0,0 +1,91 @@ +-- schema.sql +-- Database Schema for CAPY (Club Assistant in Python) + +-- 1. ENUMs & Functions +CREATE TYPE user_role AS ENUM ('student', 'alumni', 'faculty', 'external'); + +CREATE OR REPLACE FUNCTION update_modified_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.date_modified = CURRENT_DATE; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 2. Tables +CREATE TABLE IF NOT EXISTS users ( + uid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + personal_email TEXT UNIQUE, + school_email TEXT UNIQUE, + phone TEXT, + grad_year INT, + role user_role DEFAULT 'student', + date_created DATE DEFAULT CURRENT_DATE, + date_modified DATE DEFAULT CURRENT_DATE +); + +CREATE TABLE IF NOT EXISTS organizations ( + oid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + date_created DATE DEFAULT CURRENT_DATE, + date_modified DATE DEFAULT CURRENT_DATE +); + +CREATE TABLE IF NOT EXISTS org_members ( + uid UUID REFERENCES users(uid) ON DELETE CASCADE, + oid UUID REFERENCES organizations(oid) ON DELETE CASCADE, + is_admin BOOLEAN DEFAULT FALSE, + date_joined DATE DEFAULT CURRENT_DATE, + last_active DATE DEFAULT CURRENT_DATE, + PRIMARY KEY (uid, oid) +); + +CREATE TABLE IF NOT EXISTS events ( + eid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location TEXT, + event_time TIMESTAMP, + description TEXT, + date_created DATE DEFAULT CURRENT_DATE, + date_modified DATE DEFAULT CURRENT_DATE +); + +CREATE TABLE IF NOT EXISTS event_hosting ( + eid UUID REFERENCES events(eid) ON DELETE CASCADE, + oid UUID REFERENCES organizations(oid) ON DELETE CASCADE, + PRIMARY KEY (eid, oid) +); + +CREATE TABLE IF NOT EXISTS event_registrations ( + uid UUID REFERENCES users(uid) ON DELETE CASCADE, + eid UUID REFERENCES events(eid) ON DELETE CASCADE, + is_attending BOOLEAN DEFAULT FALSE, + is_admin BOOLEAN DEFAULT FALSE, + date_registered DATE DEFAULT CURRENT_DATE, + PRIMARY KEY (uid, eid) +); + +-- 3. Bot Tokens (global access for M2M authentication) +CREATE TABLE IF NOT EXISTS bot_tokens ( + token_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash TEXT NOT NULL, -- bcrypt hash of the token + name TEXT NOT NULL, -- human-readable name for the bot + created_by UUID NOT NULL REFERENCES users(uid), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP, + expires_at TIMESTAMP, -- NULL = never expires + is_active BOOLEAN DEFAULT TRUE +); + +CREATE INDEX IF NOT EXISTS idx_bot_tokens_active ON bot_tokens(is_active) WHERE is_active = TRUE; + +-- 4. Triggers +DROP TRIGGER IF EXISTS update_users_modtime ON users; +CREATE TRIGGER update_users_modtime BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + +DROP TRIGGER IF EXISTS update_orgs_modtime ON organizations; +CREATE TRIGGER update_orgs_modtime BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_modified_column(); + +DROP TRIGGER IF EXISTS update_events_modtime ON events; +CREATE TRIGGER update_events_modtime BEFORE UPDATE ON events FOR EACH ROW EXECUTE FUNCTION update_modified_column(); diff --git a/server b/server new file mode 100755 index 0000000..443d3cc Binary files /dev/null and b/server differ