diff --git a/.claude/skills/actions-runs/SKILL.md b/.claude/skills/actions-runs/SKILL.md new file mode 100644 index 0000000..4ad4ec5 --- /dev/null +++ b/.claude/skills/actions-runs/SKILL.md @@ -0,0 +1,195 @@ +--- +name: actions-runs +description: Fetch and monitor Gitea Actions CI results for a PR or branch +dependencies: tea +--- + +# Gitea Actions Run Monitoring + +Guide for inspecting CI workflow runs using the `tea actions runs` CLI. + +## Table of Contents + +- [Listing Runs](#listing-runs) +- [Viewing a Run](#viewing-a-run) +- [Viewing Logs](#viewing-logs) +- [Managing Workflows](#managing-workflows) +- [Common Workflows](#common-workflows) + +## Listing Runs + +```bash +# List recent runs (default: last 30) +tea actions runs ls --repo thatguygriff/walkies + +# Filter by branch (use this to find runs for a PR's branch) +tea actions runs ls --branch feature/my-branch --repo thatguygriff/walkies + +# Filter by status +tea actions runs ls --status failure --repo thatguygriff/walkies +tea actions runs ls --status success --repo thatguygriff/walkies +tea actions runs ls --status in_progress --repo thatguygriff/walkies +# Valid statuses: success, failure, pending, queued, in_progress, skipped, canceled + +# Filter by trigger event +tea actions runs ls --event pull_request --repo thatguygriff/walkies +tea actions runs ls --event push --repo thatguygriff/walkies + +# Filter by who triggered the run +tea actions runs ls --actor thatguygriff --repo thatguygriff/walkies + +# Filter by time range +tea actions runs ls --since 24h --repo thatguygriff/walkies +tea actions runs ls --since 2026-04-01 --until 2026-04-08 --repo thatguygriff/walkies + +# Increase result limit +tea actions runs ls --limit 50 --repo thatguygriff/walkies + +# JSON output for scripting +tea actions runs ls --output json --repo thatguygriff/walkies +``` + +## Viewing a Run + +Get the run ID from `tea actions runs ls`, then: + +```bash +# View run summary +tea actions runs view 42 --repo thatguygriff/walkies + +# View with jobs table (shows individual job statuses) +tea actions runs view 42 --jobs --repo thatguygriff/walkies + +# JSON output (includes job IDs for log fetching) +tea actions runs view 42 --output json --repo thatguygriff/walkies +``` + +## Viewing Logs + +```bash +# View logs for all jobs in a run +tea actions runs logs 42 --repo thatguygriff/walkies + +# View logs for a specific job +tea actions runs logs 42 --job JOB_ID --repo thatguygriff/walkies + +# Follow live logs for an in-progress job (requires --job) +tea actions runs logs 42 --job JOB_ID --follow --repo thatguygriff/walkies +``` + +Get job IDs from `tea actions runs view 42 --output json`. + +## Managing Workflows + +```bash +# List all workflow definitions in the repo +tea actions workflows ls --repo thatguygriff/walkies + +# Cancel or delete a run +tea actions runs delete 42 --repo thatguygriff/walkies +``` + +## Common Workflows + +### Check CI status for the current PR branch + +```bash +BRANCH=$(git branch --show-current) + +# Find the latest run for this branch +tea actions runs ls --branch "$BRANCH" --limit 5 --repo thatguygriff/walkies + +# Get the most recent run ID as JSON +RUN_ID=$(tea actions runs ls --branch "$BRANCH" --limit 1 --output json --repo thatguygriff/walkies \ + | jq -r '.[0].id') + +echo "Latest run: $RUN_ID" + +# View job-level summary +tea actions runs view "$RUN_ID" --jobs --repo thatguygriff/walkies +``` + +### Wait for CI to finish, then report status + +```bash +BRANCH=$(git branch --show-current) + +while true; do + STATUS=$(tea actions runs ls --branch "$BRANCH" --limit 1 --output json --repo thatguygriff/walkies \ + | jq -r '.[0].status') + echo "Status: $STATUS" + case "$STATUS" in + success|failure|canceled|skipped) break ;; + *) sleep 15 ;; + esac +done + +echo "Final CI status: $STATUS" +``` + +### Fetch logs for a failing run + +```bash +BRANCH=$(git branch --show-current) + +RUN_ID=$(tea actions runs ls --branch "$BRANCH" --status failure --limit 1 --output json \ + --repo thatguygriff/walkies | jq -r '.[0].id') + +# Show all logs for the failed run +tea actions runs logs "$RUN_ID" --repo thatguygriff/walkies + +# Or drill into a specific failing job +tea actions runs view "$RUN_ID" --output json --repo thatguygriff/walkies \ + | jq '.jobs[] | select(.status == "failure") | {id: .id, name: .name}' + +# Then fetch that job's logs +JOB_ID= +tea actions runs logs "$RUN_ID" --job "$JOB_ID" --repo thatguygriff/walkies +``` + +### Monitor a run that's currently in progress + +```bash +RUN_ID=42 + +# Get the in-progress job ID +JOB_ID=$(tea actions runs view "$RUN_ID" --output json --repo thatguygriff/walkies \ + | jq -r '.jobs[] | select(.status == "in_progress") | .id' | head -1) + +# Follow live logs +tea actions runs logs "$RUN_ID" --job "$JOB_ID" --follow --repo thatguygriff/walkies +``` + +## Quick Reference + +```bash +# List +tea actions runs ls # Last 30 runs +tea actions runs ls --branch feature/foo # Runs for a branch +tea actions runs ls --status failure # Failed runs only +tea actions runs ls --event pull_request # PR-triggered runs +tea actions runs ls --limit 1 --output json | jq '.[0]' # Latest run as JSON + +# View +tea actions runs view 42 # Run summary +tea actions runs view 42 --jobs # With jobs table +tea actions runs view 42 --output json # Full JSON (incl. job IDs) + +# Logs +tea actions runs logs 42 # All logs for a run +tea actions runs logs 42 --job JOB_ID # Specific job logs +tea actions runs logs 42 --job JOB_ID --follow # Follow live output + +# Cancel / delete +tea actions runs delete 42 # Cancel or delete run + +# Workflows +tea actions workflows ls # List workflow definitions +``` + +## Tips + +1. **Find the run for a PR**: PRs run on their head branch — filter with `--branch` using the feature branch name, not the PR number. +2. **Get job IDs**: Use `--output json` on `view` then `jq '.jobs[] | {id, name, status}'` to identify which job to drill into. +3. **Status polling**: The `--status in_progress` filter helps confirm a run is still going before following logs. +4. **Log noise**: Full run logs can be large — use `--job` to focus on the failing step. diff --git a/Dockerfile b/Dockerfile index baa1c30..c7a3283 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:22-alpine AS frontend WORKDIR /app/web COPY web/package*.json ./ -RUN npm ci +RUN npm install COPY web/ ./ RUN npm run build diff --git a/Taskfile.yml b/Taskfile.yml index 493c863..d0bee28 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -104,16 +104,16 @@ tasks: cmd: docker build -t walkies . docker:up: - desc: Build and start with docker-compose - cmd: docker-compose up --build + desc: Build and start with docker compose + cmd: docker compose up --build docker:down: - desc: Stop docker-compose services - cmd: docker-compose down + desc: Stop docker compose services + cmd: docker compose down docker:logs: - desc: Tail docker-compose logs - cmd: docker-compose logs -f + desc: Tail docker compose logs + cmd: docker compose logs -f # ── Utilities ──────────────────────────────────────────────────────────────── diff --git a/internal/db/db.go b/internal/db/db.go index 06c9a98..ad85461 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,9 +3,10 @@ package db import ( "context" "database/sql" + "errors" "fmt" - _ "github.com/go-sql-driver/mysql" + "github.com/go-sql-driver/mysql" ) func Open(dsn string) (*sql.DB, error) { @@ -22,6 +23,10 @@ func Open(dsn string) (*sql.DB, error) { func Migrate(ctx context.Context, db *sql.DB) error { for _, stmt := range statements { if _, err := db.ExecContext(ctx, stmt); err != nil { + var mysqlErr *mysql.MySQLError + if errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 { + continue // duplicate column — already exists + } return fmt.Errorf("migrate: %w", err) } } diff --git a/internal/db/schema.go b/internal/db/schema.go index 8af5dff..0c33f41 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -10,7 +10,7 @@ var statements = []string{ active TINYINT NOT NULL DEFAULT 1, is_trainee TINYINT NOT NULL DEFAULT 0, phone VARCHAR(20) NULL, - operational_roles TEXT NOT NULL DEFAULT '', + operational_roles TEXT NOT NULL, notification_preference VARCHAR(50) NOT NULL DEFAULT 'email', admin_notes TEXT NULL, last_login DATETIME NULL, @@ -19,15 +19,15 @@ var statements = []string{ 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`, - // Additive column migrations for existing deployments - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS admin_notes TEXT NULL`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`, - `ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`, + // Additive column migrations for existing deployments (duplicates ignored at runtime) + `ALTER TABLE volunteers ADD COLUMN is_trainee TINYINT NOT NULL DEFAULT 0`, + `ALTER TABLE volunteers ADD COLUMN phone VARCHAR(20) NULL`, + `ALTER TABLE volunteers ADD COLUMN operational_roles TEXT NOT NULL`, + `ALTER TABLE volunteers ADD COLUMN notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`, + `ALTER TABLE volunteers ADD COLUMN admin_notes TEXT NULL`, + `ALTER TABLE volunteers ADD COLUMN last_login DATETIME NULL`, + `ALTER TABLE volunteers ADD COLUMN invite_token VARCHAR(255) NULL`, + `ALTER TABLE volunteers ADD COLUMN invite_expires_at DATETIME NULL`, `CREATE TABLE IF NOT EXISTS schedules ( id INT AUTO_INCREMENT PRIMARY KEY, volunteer_id INT NOT NULL, @@ -72,10 +72,67 @@ var statements = []string{ id INT AUTO_INCREMENT PRIMARY KEY, volunteer_id INT NOT NULL, message TEXT NOT NULL, - read TINYINT NOT NULL DEFAULT 0, + is_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) + INDEX idx_is_read (is_read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + day_of_week TINYINT NOT NULL COMMENT '0=Sunday 1=Monday ... 6=Saturday (matches Go time.Weekday)', + start_time TIME NOT NULL, + end_time TIME NOT NULL, + min_capacity INT NOT NULL DEFAULT 1, + max_capacity INT 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 shift_template_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + role_name VARCHAR(255) NOT NULL, + count INT NOT NULL DEFAULT 1, + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE, + INDEX idx_template_id (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_template_volunteers ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + volunteer_id INT NOT NULL, + UNIQUE KEY uq_template_volunteer (template_id, volunteer_id), + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_instances ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NULL, + name VARCHAR(255) NOT NULL, + date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + min_capacity INT NOT NULL DEFAULT 1, + max_capacity INT NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft or published', + year INT NOT NULL, + month INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE SET NULL, + INDEX idx_year_month (year, month), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_instance_volunteers ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + volunteer_id INT NOT NULL, + confirmed TINYINT NOT NULL DEFAULT 0, + confirmed_at DATETIME NULL, + UNIQUE KEY uq_instance_volunteer (instance_id, volunteer_id), + FOREIGN KEY (instance_id) REFERENCES shift_instances(id) ON DELETE CASCADE, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + INDEX idx_instance_id (instance_id), + INDEX idx_volunteer_id (volunteer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } diff --git a/internal/notification/notification.go b/internal/notification/notification.go index d805583..6533392 100644 --- a/internal/notification/notification.go +++ b/internal/notification/notification.go @@ -14,7 +14,7 @@ type Notification struct { ID int64 `json:"id"` VolunteerID int64 `json:"volunteer_id"` Message string `json:"message"` - Read bool `json:"read"` + Read bool `json:"is_read"` CreatedAt time.Time `json:"created_at"` } @@ -42,7 +42,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) { n := &Notification{} var createdAt string err := s.db.QueryRowContext(ctx, - `SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id, + `SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE id = ?`, id, ).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -56,7 +56,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) { func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) { rows, err := s.db.QueryContext(ctx, - `SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`, + `SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`, volunteerID, ) if err != nil { @@ -77,9 +77,15 @@ func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Noti return notifications, rows.Err() } +// CreateNotification satisfies the schedule.Notifier interface. +func (s *Store) CreateNotification(ctx context.Context, volunteerID int64, message string) error { + _, err := s.Create(ctx, volunteerID, message) + return err +} + func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) { result, err := s.db.ExecContext(ctx, - `UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`, + `UPDATE notifications SET is_read = 1 WHERE id = ? AND volunteer_id = ?`, id, volunteerID, ) if err != nil { diff --git a/internal/schedule/handler.go b/internal/schedule/handler.go index 0a0272d..40b2757 100644 --- a/internal/schedule/handler.go +++ b/internal/schedule/handler.go @@ -1,99 +1,329 @@ package schedule import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" + "time" "git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/server/middleware" "github.com/go-chi/chi/v5" ) +// Notifier is the subset of notification.Store the handler needs. +type Notifier interface { + CreateNotification(ctx context.Context, volunteerID int64, message string) error +} + type Handler struct { - store *Store + store Storer + notifier Notifier } -func NewHandler(store *Store) *Handler { - return &Handler{store: store} +// Storer is the interface the Handler depends on. +type Storer interface { + CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) + GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) + ListTemplates(ctx context.Context) ([]ShiftTemplate, error) + UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) + DeleteTemplate(ctx context.Context, id int64) error + + GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) + ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) + GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) + UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (*ShiftInstance, []int64, error) + PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) + UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) + ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error } -// GET /api/v1/schedules -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - volunteerID := int64(0) - if claims.Role != "admin" { - volunteerID = claims.VolunteerID - } - schedules, err := h.store.List(r.Context(), volunteerID) +func NewHandler(store *Store, notifier Notifier) *Handler { + return &Handler{store: store, notifier: notifier} +} + +// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing. +func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler { + return &Handler{store: store, notifier: notifier} +} + +// --------------------------------------------------------------------------- +// Template handlers +// --------------------------------------------------------------------------- + +// GET /api/v1/shift-templates +func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := h.store.ListTemplates(r.Context()) if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not list schedules") + respond.Error(w, http.StatusInternalServerError, "could not list templates") return } - if schedules == nil { - schedules = []Schedule{} + if templates == nil { + templates = []ShiftTemplate{} } - respond.JSON(w, http.StatusOK, schedules) + respond.JSON(w, http.StatusOK, templates) } -// POST /api/v1/schedules -func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - var in CreateInput +// POST /api/v1/shift-templates +func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) { + var in CreateTemplateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } - if claims.Role != "admin" { - in.VolunteerID = claims.VolunteerID - } - if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" { - respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required") + if in.Name == "" || in.StartTime == "" || in.EndTime == "" { + respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required") return } - sc, err := h.store.Create(r.Context(), in) + if in.MinCapacity <= 0 { + in.MinCapacity = 1 + } + if in.MaxCapacity < in.MinCapacity { + in.MaxCapacity = in.MinCapacity + } + t, err := h.store.CreateTemplate(r.Context(), in) if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not create schedule") + respond.Error(w, http.StatusInternalServerError, "could not create template") return } - respond.JSON(w, http.StatusCreated, sc) + respond.JSON(w, http.StatusCreated, t) } -// PUT /api/v1/schedules/{id} -func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { +// PUT /api/v1/shift-templates/{id} +func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid id") return } - var in UpdateInput + var in UpdateTemplateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } - sc, err := h.store.Update(r.Context(), id, in) + t, err := h.store.UpdateTemplate(r.Context(), id, in) if errors.Is(err, ErrNotFound) { - respond.Error(w, http.StatusNotFound, "schedule not found") + respond.Error(w, http.StatusNotFound, "template not found") return } if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not update schedule") + respond.Error(w, http.StatusInternalServerError, "could not update template") return } - respond.JSON(w, http.StatusOK, sc) + respond.JSON(w, http.StatusOK, t) } -// DELETE /api/v1/schedules/{id} -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { +// DELETE /api/v1/shift-templates/{id} +func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid id") return } - if err := h.store.Delete(r.Context(), id); err != nil { - respond.Error(w, http.StatusInternalServerError, "could not delete schedule") + if err := h.store.DeleteTemplate(r.Context(), id); err != nil { + respond.Error(w, http.StatusInternalServerError, "could not delete template") return } w.WriteHeader(http.StatusNoContent) } + +// --------------------------------------------------------------------------- +// Instance handlers +// --------------------------------------------------------------------------- + +// GET /api/v1/shifts?year=2026&month=4 +func (h *Handler) ListInstances(w http.ResponseWriter, r *http.Request) { + year, month := parseYearMonth(r) + claims := middleware.ClaimsFromContext(r.Context()) + + volunteerID := int64(0) + if claims.Role != "admin" { + volunteerID = claims.VolunteerID + } + + instances, err := h.store.ListInstances(r.Context(), year, month, volunteerID) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not list shifts") + return + } + if instances == nil { + instances = []ShiftInstance{} + } + respond.JSON(w, http.StatusOK, instances) +} + +// POST /api/v1/shifts/generate body: {"year":2026,"month":4} +func (h *Handler) GenerateInstances(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + instances, err := h.store.GenerateInstances(r.Context(), body.Year, body.Month) + if errors.Is(err, ErrAlreadyExists) { + respond.Error(w, http.StatusConflict, "shifts already generated for this period") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not generate shifts") + return + } + respond.JSON(w, http.StatusCreated, instances) +} + +// POST /api/v1/shifts/publish body: {"year":2026,"month":4} +func (h *Handler) PublishMonth(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + + byVol, err := h.store.PublishMonth(r.Context(), body.Year, body.Month) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not publish schedule") + return + } + + // Notify each affected volunteer (FR-S04) + mn := time.Month(body.Month).String() + for vid, shifts := range byVol { + msg := fmt.Sprintf("Your schedule for %s %d has been published. You have %d shift(s).", + mn, body.Year, len(shifts)) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + + respond.JSON(w, http.StatusOK, map[string]any{ + "year": body.Year, + "month": body.Month, + }) +} + +// POST /api/v1/shifts/unpublish body: {"year":2026,"month":4} +func (h *Handler) UnpublishMonth(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + + volunteerIDs, err := h.store.UnpublishMonth(r.Context(), body.Year, body.Month) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not unpublish schedule") + return + } + + // Notify affected volunteers (FR-S05) + mn := time.Month(body.Month).String() + for _, vid := range volunteerIDs { + msg := fmt.Sprintf("The schedule for %s %d has been retracted.", mn, body.Year) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + + respond.JSON(w, http.StatusOK, map[string]any{ + "year": body.Year, + "month": body.Month, + }) +} + +// PUT /api/v1/shifts/{id} +func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid id") + return + } + var in UpdateInstanceInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + + inst, added, err := h.store.UpdateInstance(r.Context(), id, in) + if errors.Is(err, ErrNotFound) { + respond.Error(w, http.StatusNotFound, "shift not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not update shift") + return + } + + // Notify all volunteers on a published shift of the change (FR-S09) + if inst.Status == "published" && in.VolunteerIDs != nil { + for _, v := range inst.Volunteers { + msg := fmt.Sprintf("Your shift on %s (%s–%s) has been updated. Please re-confirm.", inst.Date, inst.StartTime, inst.EndTime) + h.notifier.CreateNotification(r.Context(), v.VolunteerID, msg) //nolint:errcheck + } + } + + // Notify only newly added volunteers (FR-S10) + if inst.Status == "published" && len(added) > 0 { + for _, vid := range added { + msg := fmt.Sprintf("You have been added to a shift on %s (%s–%s).", inst.Date, inst.StartTime, inst.EndTime) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + } + + respond.JSON(w, http.StatusOK, inst) +} + +// POST /api/v1/shifts/{id}/confirm +func (h *Handler) ConfirmShift(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid id") + return + } + claims := middleware.ClaimsFromContext(r.Context()) + if err := h.store.ConfirmShift(r.Context(), id, claims.VolunteerID); err != nil { + if errors.Is(err, ErrNotFound) { + respond.Error(w, http.StatusNotFound, "shift assignment not found") + return + } + respond.Error(w, http.StatusInternalServerError, "could not confirm shift") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func parseYearMonth(r *http.Request) (year, month int) { + now := time.Now() + year = now.Year() + month = int(now.Month()) + if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil { + year = y + } + if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 { + month = m + } + return year, month +} diff --git a/internal/schedule/handler_test.go b/internal/schedule/handler_test.go new file mode 100644 index 0000000..dd7674b --- /dev/null +++ b/internal/schedule/handler_test.go @@ -0,0 +1,476 @@ +package schedule_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.unsupervised.ca/walkies/internal/auth" + "git.unsupervised.ca/walkies/internal/schedule" + "git.unsupervised.ca/walkies/internal/server/middleware" + "github.com/go-chi/chi/v5" +) + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +type fakeStore struct { + templates []schedule.ShiftTemplate + instances []schedule.ShiftInstance + createTmpl *schedule.ShiftTemplate + createErr error + updateTmpl *schedule.ShiftTemplate + updateErr error + deleteErr error + genResult []schedule.ShiftInstance + genErr error + publishResult map[int64][]schedule.ShiftInstance + publishErr error + unpubResult []int64 + unpubErr error + updateInst *schedule.ShiftInstance + addedVols []int64 + updateInstErr error + confirmErr error +} + +func (f *fakeStore) CreateTemplate(_ context.Context, in schedule.CreateTemplateInput) (*schedule.ShiftTemplate, error) { + if f.createErr != nil { + return nil, f.createErr + } + if f.createTmpl != nil { + return f.createTmpl, nil + } + return &schedule.ShiftTemplate{ID: 1, Name: in.Name, DayOfWeek: in.DayOfWeek, + StartTime: in.StartTime, EndTime: in.EndTime, + MinCapacity: in.MinCapacity, MaxCapacity: in.MaxCapacity, + Roles: []schedule.TemplateRole{}, VolunteerIDs: []int64{}}, nil +} + +func (f *fakeStore) GetTemplate(_ context.Context, id int64) (*schedule.ShiftTemplate, error) { + for _, t := range f.templates { + if t.ID == id { + return &t, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) ListTemplates(_ context.Context) ([]schedule.ShiftTemplate, error) { + return f.templates, nil +} + +func (f *fakeStore) UpdateTemplate(_ context.Context, id int64, _ schedule.UpdateTemplateInput) (*schedule.ShiftTemplate, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + if f.updateTmpl != nil { + return f.updateTmpl, nil + } + for _, t := range f.templates { + if t.ID == id { + return &t, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) DeleteTemplate(_ context.Context, _ int64) error { + return f.deleteErr +} + +func (f *fakeStore) GenerateInstances(_ context.Context, _, _ int) ([]schedule.ShiftInstance, error) { + return f.genResult, f.genErr +} + +func (f *fakeStore) ListInstances(_ context.Context, _, _ int, _ int64) ([]schedule.ShiftInstance, error) { + return f.instances, nil +} + +func (f *fakeStore) GetInstance(_ context.Context, id int64) (*schedule.ShiftInstance, error) { + for _, inst := range f.instances { + if inst.ID == id { + return &inst, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) UpdateInstance(_ context.Context, _ int64, _ schedule.UpdateInstanceInput) (*schedule.ShiftInstance, []int64, error) { + return f.updateInst, f.addedVols, f.updateInstErr +} + +func (f *fakeStore) PublishMonth(_ context.Context, _, _ int) (map[int64][]schedule.ShiftInstance, error) { + return f.publishResult, f.publishErr +} + +func (f *fakeStore) UnpublishMonth(_ context.Context, _, _ int) ([]int64, error) { + return f.unpubResult, f.unpubErr +} + +func (f *fakeStore) ConfirmShift(_ context.Context, _, _ int64) error { + return f.confirmErr +} + +type fakeNotifier struct { + calls []struct { + volunteerID int64 + message string + } +} + +func (n *fakeNotifier) CreateNotification(_ context.Context, volunteerID int64, message string) error { + n.calls = append(n.calls, struct { + volunteerID int64 + message string + }{volunteerID, message}) + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func jwtForRole(t *testing.T, id int64, role string) string { + t.Helper() + svc := auth.NewService(nil, "test-secret") + token, err := svc.IssueToken(id, role) + if err != nil { + t.Fatalf("issue token: %v", err) + } + return token +} + +func newRouter(h *schedule.Handler) http.Handler { + realAuthSvc := auth.NewService(nil, "test-secret") + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(middleware.Authenticate(realAuthSvc)) + + r.Get("/api/v1/shift-templates", h.ListTemplates) + r.Post("/api/v1/shift-templates", + middleware.RequireAdmin(http.HandlerFunc(h.CreateTemplate)).ServeHTTP) + r.Put("/api/v1/shift-templates/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.UpdateTemplate)).ServeHTTP) + r.Delete("/api/v1/shift-templates/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.DeleteTemplate)).ServeHTTP) + + r.Get("/api/v1/shifts", h.ListInstances) + r.Post("/api/v1/shifts/generate", + middleware.RequireAdmin(http.HandlerFunc(h.GenerateInstances)).ServeHTTP) + r.Post("/api/v1/shifts/publish", + middleware.RequireAdmin(http.HandlerFunc(h.PublishMonth)).ServeHTTP) + r.Post("/api/v1/shifts/unpublish", + middleware.RequireAdmin(http.HandlerFunc(h.UnpublishMonth)).ServeHTTP) + r.Put("/api/v1/shifts/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.UpdateInstance)).ServeHTTP) + r.Post("/api/v1/shifts/{id}/confirm", h.ConfirmShift) + }) + return r +} + +func do(t *testing.T, router http.Handler, method, path, body, token string) *httptest.ResponseRecorder { + t.Helper() + var b *bytes.Reader + if body != "" { + b = bytes.NewReader([]byte(body)) + } else { + b = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, b) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// --------------------------------------------------------------------------- +// Template tests +// --------------------------------------------------------------------------- + +func TestListTemplates_ReturnsEmpty(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "GET", "/api/v1/shift-templates", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var result []schedule.ShiftTemplate + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("expected empty list, got %d items", len(result)) + } +} + +func TestCreateTemplate_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, + token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestCreateTemplate_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, + token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var tmpl schedule.ShiftTemplate + json.NewDecoder(w.Body).Decode(&tmpl) + if tmpl.Name != "Morning" { + t.Errorf("expected name Morning, got %q", tmpl.Name) + } +} + +func TestCreateTemplate_MissingFields(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"day_of_week":1}`, token) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestDeleteTemplate_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestDeleteTemplate_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +// --------------------------------------------------------------------------- +// Instance tests +// --------------------------------------------------------------------------- + +func TestGenerateInstances_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestGenerateInstances_AlreadyExists(t *testing.T) { + store := &fakeStore{genErr: schedule.ErrAlreadyExists} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body) + } +} + +func TestGenerateInstances_Success(t *testing.T) { + store := &fakeStore{ + genResult: []schedule.ShiftInstance{ + {ID: 1, Name: "Morning", Date: "2026-04-06", Status: "draft", Volunteers: []schedule.InstanceVolunteer{}}, + }, + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var result []schedule.ShiftInstance + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 1 { + t.Errorf("expected 1 instance, got %d", len(result)) + } +} + +func TestPublishMonth_SendsNotifications(t *testing.T) { + store := &fakeStore{ + publishResult: map[int64][]schedule.ShiftInstance{ + 10: {{ID: 1, Name: "Morning", Date: "2026-04-06", Volunteers: []schedule.InstanceVolunteer{}}}, + 20: {{ID: 2, Name: "Morning", Date: "2026-04-13", Volunteers: []schedule.InstanceVolunteer{}}}, + }, + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/publish", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if len(notifier.calls) != 2 { + t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) + } +} + +func TestUnpublishMonth_SendsNotifications(t *testing.T) { + store := &fakeStore{unpubResult: []int64{10, 20}} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/unpublish", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if len(notifier.calls) != 2 { + t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) + } +} + +func TestUpdateInstance_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "PUT", "/api/v1/shifts/1", + `{"volunteer_ids":[5]}`, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) { + vids := []int64{5, 6} + store := &fakeStore{ + updateInst: &schedule.ShiftInstance{ + ID: 1, Status: "published", Date: "2026-04-06", + StartTime: "09:00:00", EndTime: "12:00:00", + Volunteers: []schedule.InstanceVolunteer{ + {InstanceID: 1, VolunteerID: 5, Name: "Alice"}, + {InstanceID: 1, VolunteerID: 6, Name: "Bob"}, + }, + }, + addedVols: []int64{6}, // Bob is newly added + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + body, _ := json.Marshal(map[string]any{"volunteer_ids": vids}) + w := do(t, router, "PUT", "/api/v1/shifts/1", string(body), token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + // 2 notifications for existing volunteers (reset) + 1 for newly added + // Total = 3 but FR-S10 is a subset of FR-S09, so we don't double-count + // The handler sends update notices to all current volunteers (2) and + // an "added" notice only to the newly added one (1) = 3 notifications. + if len(notifier.calls) != 3 { + t.Errorf("expected 3 notifications (2 reset + 1 added), got %d", len(notifier.calls)) + } +} + +func TestConfirmShift_NotAssigned(t *testing.T) { + store := &fakeStore{confirmErr: schedule.ErrNotFound} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 5, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/99/confirm", "", token) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestConfirmShift_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 5, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/1/confirm", "", token) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +// Compile-time interface check +var _ schedule.Storer = (*fakeStore)(nil) +var _ schedule.Notifier = (*fakeNotifier)(nil) diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 4cba542..ff8ae07 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -8,35 +8,96 @@ import ( "time" ) -var ErrNotFound = fmt.Errorf("schedule not found") +var ( + ErrNotFound = fmt.Errorf("not found") + ErrAlreadyExists = fmt.Errorf("instances already generated for this period") +) -type Schedule struct { - ID int64 `json:"id"` - VolunteerID int64 `json:"volunteer_id"` - Title string `json:"title"` - StartsAt time.Time `json:"starts_at"` - EndsAt time.Time `json:"ends_at"` - Notes string `json:"notes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +type ShiftTemplate struct { + ID int64 `json:"id"` + Name string `json:"name"` + DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat + StartTime string `json:"start_time"` // "HH:MM:SS" + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -type CreateInput struct { - VolunteerID int64 `json:"volunteer_id"` - Title string `json:"title"` - StartsAt string `json:"starts_at"` - EndsAt string `json:"ends_at"` - Notes string `json:"notes"` +type TemplateRole struct { + ID int64 `json:"id"` + TemplateID int64 `json:"template_id"` + RoleName string `json:"role_name"` + Count int `json:"count"` } -type UpdateInput struct { - Title *string `json:"title"` - StartsAt *string `json:"starts_at"` - EndsAt *string `json:"ends_at"` - Notes *string `json:"notes"` +type ShiftInstance struct { + ID int64 `json:"id"` + TemplateID *int64 `json:"template_id,omitempty"` + Name string `json:"name"` + Date string `json:"date"` // "YYYY-MM-DD" + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Status string `json:"status"` // "draft" or "published" + Year int `json:"year"` + Month int `json:"month"` + Volunteers []InstanceVolunteer `json:"volunteers"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -const timeLayout = "2006-01-02T15:04:05Z" +type InstanceVolunteer struct { + InstanceID int64 `json:"instance_id"` + VolunteerID int64 `json:"volunteer_id"` + Name string `json:"name"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` +} + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +type CreateTemplateInput struct { + Name string `json:"name"` + DayOfWeek int `json:"day_of_week"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` +} + +type UpdateTemplateInput struct { + Name *string `json:"name"` + DayOfWeek *int `json:"day_of_week"` + StartTime *string `json:"start_time"` + EndTime *string `json:"end_time"` + MinCapacity *int `json:"min_capacity"` + MaxCapacity *int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` +} + +type UpdateInstanceInput struct { + VolunteerIDs *[]int64 `json:"volunteer_ids"` + MinCapacity *int `json:"min_capacity"` + MaxCapacity *int `json:"max_capacity"` +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- type Store struct { db *sql.DB @@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store { return &Store{db: db} } -func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) { - res, err := s.db.ExecContext(ctx, - `INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`, - in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes, +// --------------------------------------------------------------------------- +// Template operations +// --------------------------------------------------------------------------- + +func (s *Store) CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, + `INSERT INTO shift_templates (name, day_of_week, start_time, end_time, min_capacity, max_capacity) + VALUES (?, ?, ?, ?, ?, ?)`, + in.Name, in.DayOfWeek, in.StartTime, in.EndTime, in.MinCapacity, in.MaxCapacity, ) if err != nil { - return nil, fmt.Errorf("insert schedule: %w", err) + return nil, fmt.Errorf("insert template: %w", err) } id, _ := res.LastInsertId() - return s.GetByID(ctx, id) + + if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil { + return nil, err + } + if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetTemplate(ctx, id) } -func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) { - sc := &Schedule{} - var startsAt, endsAt, createdAt, updatedAt string - var notes sql.NullString +func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) { + t := &ShiftTemplate{} + var createdAt, updatedAt string err := s.db.QueryRowContext(ctx, - `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id, - ).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt) + `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at + FROM shift_templates WHERE id = ?`, id, + ).Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime, + &t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { - return nil, fmt.Errorf("get schedule: %w", err) + return nil, fmt.Errorf("get template: %w", err) } - sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) - sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) - sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - if notes.Valid { - sc.Notes = notes.String - } - return sc, nil -} + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) -func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) { - query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules` - args := []any{} - if volunteerID > 0 { - query += ` WHERE volunteer_id = ?` - args = append(args, volunteerID) - } - query += ` ORDER BY starts_at` - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("list schedules: %w", err) - } - defer rows.Close() - - var schedules []Schedule - for rows.Next() { - var sc Schedule - var startsAt, endsAt, createdAt, updatedAt string - var notes sql.NullString - if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil { - return nil, err - } - sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) - sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) - sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - if notes.Valid { - sc.Notes = notes.String - } - schedules = append(schedules, sc) - } - return schedules, rows.Err() -} - -func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) { - sc, err := s.GetByID(ctx, id) + roles, err := s.templateRoles(ctx, id) if err != nil { return nil, err } - title := sc.Title - startsAt := sc.StartsAt.Format("2006-01-02 15:04:05") - endsAt := sc.EndsAt.Format("2006-01-02 15:04:05") - notes := sc.Notes + t.Roles = roles - if in.Title != nil { - title = *in.Title + vids, err := s.templateVolunteerIDs(ctx, id) + if err != nil { + return nil, err } - if in.StartsAt != nil { - startsAt = *in.StartsAt + t.VolunteerIDs = vids + return t, nil +} + +func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at + FROM shift_templates ORDER BY day_of_week, start_time`) + if err != nil { + return nil, fmt.Errorf("list templates: %w", err) } - if in.EndsAt != nil { - endsAt = *in.EndsAt + defer rows.Close() + + var templates []ShiftTemplate + for rows.Next() { + var t ShiftTemplate + var createdAt, updatedAt string + if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime, + &t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + templates = append(templates, t) } - if in.Notes != nil { - notes = *in.Notes + if err := rows.Err(); err != nil { + return nil, err } - _, err = s.db.ExecContext(ctx, - `UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`, - title, startsAt, endsAt, notes, id, + + // Load roles and volunteers for each template + for i := range templates { + roles, err := s.templateRoles(ctx, templates[i].ID) + if err != nil { + return nil, err + } + templates[i].Roles = roles + + vids, err := s.templateVolunteerIDs(ctx, templates[i].ID) + if err != nil { + return nil, err + } + templates[i].VolunteerIDs = vids + } + return templates, nil +} + +func (s *Store) UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) { + t, err := s.GetTemplate(ctx, id) + if err != nil { + return nil, err + } + + name := t.Name + dow := t.DayOfWeek + startTime := t.StartTime + endTime := t.EndTime + minCap := t.MinCapacity + maxCap := t.MaxCapacity + + if in.Name != nil { + name = *in.Name + } + if in.DayOfWeek != nil { + dow = *in.DayOfWeek + } + if in.StartTime != nil { + startTime = *in.StartTime + } + if in.EndTime != nil { + endTime = *in.EndTime + } + if in.MinCapacity != nil { + minCap = *in.MinCapacity + } + if in.MaxCapacity != nil { + maxCap = *in.MaxCapacity + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, + `UPDATE shift_templates SET name=?, day_of_week=?, start_time=?, end_time=?, + min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`, + name, dow, startTime, endTime, minCap, maxCap, id, ) if err != nil { - return nil, fmt.Errorf("update schedule: %w", err) + return nil, fmt.Errorf("update template: %w", err) } - return s.GetByID(ctx, id) + + if in.Roles != nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_roles WHERE template_id = ?`, id); err != nil { + return nil, fmt.Errorf("clear roles: %w", err) + } + if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil { + return nil, err + } + } + if in.VolunteerIDs != nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_volunteers WHERE template_id = ?`, id); err != nil { + return nil, fmt.Errorf("clear volunteers: %w", err) + } + if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil { + return nil, err + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetTemplate(ctx, id) } -func (s *Store) Delete(ctx context.Context, id int64) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id) +func (s *Store) DeleteTemplate(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id) return err } + +// --------------------------------------------------------------------------- +// Instance operations +// --------------------------------------------------------------------------- + +// GenerateInstances creates draft shift instances for every template × date in +// the given month. Returns ErrAlreadyExists if instances already exist for +// that month (FR-S02). +func (s *Store) GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) { + var count int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM shift_instances WHERE year = ? AND month = ?`, year, month, + ).Scan(&count); err != nil { + return nil, fmt.Errorf("check existing: %w", err) + } + if count > 0 { + return nil, ErrAlreadyExists + } + + templates, err := s.ListTemplates(ctx) + if err != nil { + return nil, err + } + + // Find all dates in the month for each template's day of week + first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + daysInMonth := daysIn(year, month) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var instanceIDs []int64 + for _, tmpl := range templates { + for d := 0; d < daysInMonth; d++ { + day := first.AddDate(0, 0, d) + if int(day.Weekday()) != tmpl.DayOfWeek { + continue + } + res, err := tx.ExecContext(ctx, + `INSERT INTO shift_instances + (template_id, name, date, start_time, end_time, min_capacity, max_capacity, status, year, month) + VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, + tmpl.ID, tmpl.Name, day.Format("2006-01-02"), + tmpl.StartTime, tmpl.EndTime, + tmpl.MinCapacity, tmpl.MaxCapacity, + year, month, + ) + if err != nil { + return nil, fmt.Errorf("insert instance: %w", err) + } + instID, _ := res.LastInsertId() + instanceIDs = append(instanceIDs, instID) + + // Copy recurring volunteer assignments from template + for _, vid := range tmpl.VolunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`, + instID, vid, + ); err != nil { + return nil, fmt.Errorf("copy volunteer: %w", err) + } + } + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return s.ListInstances(ctx, year, month, 0) +} + +// ListInstances returns instances for a month. When volunteerID > 0, only +// returns published instances where that volunteer is assigned. +func (s *Store) ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) { + query := `SELECT id, template_id, name, date, start_time, end_time, + min_capacity, max_capacity, status, year, month, created_at, updated_at + FROM shift_instances WHERE year = ? AND month = ?` + args := []any{year, month} + + if volunteerID > 0 { + query += ` AND status = 'published' + AND id IN (SELECT instance_id FROM shift_instance_volunteers WHERE volunteer_id = ?)` + args = append(args, volunteerID) + } + query += ` ORDER BY date, start_time` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list instances: %w", err) + } + defer rows.Close() + + var instances []ShiftInstance + for rows.Next() { + inst, err := scanInstance(rows) + if err != nil { + return nil, err + } + instances = append(instances, *inst) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for i := range instances { + vols, err := s.instanceVolunteers(ctx, instances[i].ID) + if err != nil { + return nil, err + } + instances[i].Volunteers = vols + } + return instances, nil +} + +func (s *Store) GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, template_id, name, date, start_time, end_time, + min_capacity, max_capacity, status, year, month, created_at, updated_at + FROM shift_instances WHERE id = ?`, id) + inst, err := scanInstanceRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get instance: %w", err) + } + vols, err := s.instanceVolunteers(ctx, inst.ID) + if err != nil { + return nil, err + } + inst.Volunteers = vols + return inst, nil +} + +// UpdateInstance edits volunteer assignments and/or capacity on any instance. +// For published instances, volunteer confirmation statuses are reset (FR-S09). +// Returns the previous and new volunteer ID sets so the caller can send notifications. +func (s *Store) UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (inst *ShiftInstance, added []int64, err error) { + inst, err = s.GetInstance(ctx, id) + if err != nil { + return nil, nil, err + } + + tx, txErr := s.db.BeginTx(ctx, nil) + if txErr != nil { + return nil, nil, fmt.Errorf("begin tx: %w", txErr) + } + defer tx.Rollback() + + if in.MinCapacity != nil || in.MaxCapacity != nil { + minCap := inst.MinCapacity + maxCap := inst.MaxCapacity + if in.MinCapacity != nil { + minCap = *in.MinCapacity + } + if in.MaxCapacity != nil { + maxCap = *in.MaxCapacity + } + if _, err := tx.ExecContext(ctx, + `UPDATE shift_instances SET min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`, + minCap, maxCap, id, + ); err != nil { + return nil, nil, fmt.Errorf("update capacity: %w", err) + } + } + + if in.VolunteerIDs != nil { + // Determine newly added volunteers (FR-S10) + existing := make(map[int64]bool) + for _, v := range inst.Volunteers { + existing[v.VolunteerID] = true + } + for _, vid := range *in.VolunteerIDs { + if !existing[vid] { + added = append(added, vid) + } + } + + // Replace assignments + if _, err := tx.ExecContext(ctx, + `DELETE FROM shift_instance_volunteers WHERE instance_id = ?`, id, + ); err != nil { + return nil, nil, fmt.Errorf("clear volunteers: %w", err) + } + for _, vid := range *in.VolunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`, + id, vid, + ); err != nil { + return nil, nil, fmt.Errorf("insert volunteer: %w", err) + } + } + + // Reset confirmation for published shifts (FR-S09) + if inst.Status == "published" { + if _, err := tx.ExecContext(ctx, + `UPDATE shift_instance_volunteers SET confirmed=0, confirmed_at=NULL WHERE instance_id=?`, id, + ); err != nil { + return nil, nil, fmt.Errorf("reset confirmations: %w", err) + } + } + } + + if err := tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit: %w", err) + } + + inst, err = s.GetInstance(ctx, id) + return inst, added, err +} + +// PublishMonth marks all draft instances for the month as published and returns +// a map of volunteerID → []ShiftInstance for notification purposes (FR-S04). +func (s *Store) PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) { + if _, err := s.db.ExecContext(ctx, + `UPDATE shift_instances SET status='published', updated_at=NOW() + WHERE year=? AND month=? AND status='draft'`, year, month, + ); err != nil { + return nil, fmt.Errorf("publish: %w", err) + } + + instances, err := s.ListInstances(ctx, year, month, 0) + if err != nil { + return nil, err + } + + byVol := make(map[int64][]ShiftInstance) + for _, inst := range instances { + for _, v := range inst.Volunteers { + byVol[v.VolunteerID] = append(byVol[v.VolunteerID], inst) + } + } + return byVol, nil +} + +// UnpublishMonth marks all published instances for the month back to draft and +// returns volunteer IDs who had assignments (for notifications FR-S05). +func (s *Store) UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) { + // Collect affected volunteer IDs before unpublishing + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT siv.volunteer_id + FROM shift_instance_volunteers siv + JOIN shift_instances si ON siv.instance_id = si.id + WHERE si.year=? AND si.month=? AND si.status='published'`, year, month, + ) + if err != nil { + return nil, fmt.Errorf("query volunteers: %w", err) + } + defer rows.Close() + + var volunteerIDs []int64 + for rows.Next() { + var vid int64 + if err := rows.Scan(&vid); err != nil { + return nil, err + } + volunteerIDs = append(volunteerIDs, vid) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if _, err := s.db.ExecContext(ctx, + `UPDATE shift_instances SET status='draft', updated_at=NOW() + WHERE year=? AND month=? AND status='published'`, year, month, + ); err != nil { + return nil, fmt.Errorf("unpublish: %w", err) + } + + return volunteerIDs, nil +} + +// ConfirmShift marks a volunteer's attendance confirmation for a shift (FR-S06). +func (s *Store) ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error { + result, err := s.db.ExecContext(ctx, + `UPDATE shift_instance_volunteers SET confirmed=1, confirmed_at=NOW() + WHERE instance_id=? AND volunteer_id=?`, instanceID, volunteerID, + ) + if err != nil { + return fmt.Errorf("confirm shift: %w", err) + } + affected, _ := result.RowsAffected() + if affected == 0 { + return ErrNotFound + } + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]TemplateRole, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, template_id, role_name, count FROM shift_template_roles WHERE template_id = ?`, templateID) + if err != nil { + return nil, fmt.Errorf("get template roles: %w", err) + } + defer rows.Close() + roles := make([]TemplateRole, 0) + for rows.Next() { + var r TemplateRole + if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil { + return nil, err + } + roles = append(roles, r) + } + return roles, rows.Err() +} + +func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT volunteer_id FROM shift_template_volunteers WHERE template_id = ?`, templateID) + if err != nil { + return nil, fmt.Errorf("get template volunteers: %w", err) + } + defer rows.Close() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]InstanceVolunteer, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT siv.instance_id, siv.volunteer_id, v.name, siv.confirmed, siv.confirmed_at + FROM shift_instance_volunteers siv + JOIN volunteers v ON v.id = siv.volunteer_id + WHERE siv.instance_id = ? + ORDER BY v.name`, instanceID, + ) + if err != nil { + return nil, fmt.Errorf("get instance volunteers: %w", err) + } + defer rows.Close() + vols := make([]InstanceVolunteer, 0) + for rows.Next() { + var iv InstanceVolunteer + var confirmedAt sql.NullString + if err := rows.Scan(&iv.InstanceID, &iv.VolunteerID, &iv.Name, &iv.Confirmed, &confirmedAt); err != nil { + return nil, err + } + if confirmedAt.Valid { + t, _ := time.Parse("2006-01-02 15:04:05", confirmedAt.String) + iv.ConfirmedAt = &t + } + vols = append(vols, iv) + } + return vols, rows.Err() +} + +func upsertTemplateRoles(ctx context.Context, tx *sql.Tx, templateID int64, roles []TemplateRole) error { + for _, r := range roles { + if _, err := tx.ExecContext(ctx, + `INSERT INTO shift_template_roles (template_id, role_name, count) VALUES (?, ?, ?)`, + templateID, r.RoleName, r.Count, + ); err != nil { + return fmt.Errorf("insert role: %w", err) + } + } + return nil +} + +func upsertTemplateVolunteers(ctx context.Context, tx *sql.Tx, templateID int64, volunteerIDs []int64) error { + for _, vid := range volunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT IGNORE INTO shift_template_volunteers (template_id, volunteer_id) VALUES (?, ?)`, + templateID, vid, + ); err != nil { + return fmt.Errorf("insert template volunteer: %w", err) + } + } + return nil +} + +type instanceScanner interface { + Scan(dest ...any) error +} + +func scanInstance(r instanceScanner) (*ShiftInstance, error) { + var inst ShiftInstance + var templateID sql.NullInt64 + var createdAt, updatedAt string + if err := r.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime, + &inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month, + &createdAt, &updatedAt); err != nil { + return nil, err + } + if templateID.Valid { + inst.TemplateID = &templateID.Int64 + } + inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + if inst.Volunteers == nil { + inst.Volunteers = []InstanceVolunteer{} + } + return &inst, nil +} + +func scanInstanceRow(row *sql.Row) (*ShiftInstance, error) { + var inst ShiftInstance + var templateID sql.NullInt64 + var createdAt, updatedAt string + if err := row.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime, + &inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month, + &createdAt, &updatedAt); err != nil { + return nil, err + } + if templateID.Valid { + inst.TemplateID = &templateID.Int64 + } + inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + if inst.Volunteers == nil { + inst.Volunteers = []InstanceVolunteer{} + } + return &inst, nil +} + +func daysIn(year, month int) int { + return time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC).Day() +} diff --git a/internal/server/server.go b/internal/server/server.go index c4165b6..a1f6d01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "git.unsupervised.ca/walkies/internal/notification" "git.unsupervised.ca/walkies/internal/schedule" "git.unsupervised.ca/walkies/internal/server/middleware" + "git.unsupervised.ca/walkies/internal/setup" "git.unsupervised.ca/walkies/internal/timeoff" "git.unsupervised.ca/walkies/internal/volunteer" ) @@ -22,8 +23,11 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { volunteerStore := volunteer.NewStore(db) volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc) + notificationStore := notification.NewStore(db) + notificationHandler := notification.NewHandler(notificationStore) + scheduleStore := schedule.NewStore(db) - scheduleHandler := schedule.NewHandler(scheduleStore) + scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore) timeoffStore := timeoff.NewStore(db) timeoffHandler := timeoff.NewHandler(timeoffStore) @@ -31,8 +35,8 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { checkinStore := checkin.NewStore(db) checkinHandler := checkin.NewHandler(checkinStore) - notificationStore := notification.NewStore(db) - notificationHandler := notification.NewHandler(notificationStore) + setupStore := setup.NewStore(db) + setupHandler := setup.NewHandler(setupStore, authSvc) r := chi.NewRouter() r.Use(chimiddleware.Logger) @@ -45,6 +49,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { r.Post("/auth/login", volunteerHandler.Login) r.Post("/auth/activate", volunteerHandler.Activate) + // Public setup endpoints (self-disabling once first user exists) + r.Get("/setup/status", setupHandler.Status) + r.Post("/setup/admin", setupHandler.CreateAdmin) + // Protected routes r.Group(func(r chi.Router) { r.Use(middleware.Authenticate(authSvc)) @@ -56,11 +64,19 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { r.Put("/volunteers/{id}", volunteerHandler.Update) r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite) - // Schedules - r.Get("/schedules", scheduleHandler.List) - r.Post("/schedules", scheduleHandler.Create) - r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update) - r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete) + // Shift templates (admin only) + r.Get("/shift-templates", scheduleHandler.ListTemplates) + r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate) + r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate) + r.With(middleware.RequireAdmin).Delete("/shift-templates/{id}", scheduleHandler.DeleteTemplate) + + // Shift instances + r.Get("/shifts", scheduleHandler.ListInstances) + r.With(middleware.RequireAdmin).Post("/shifts/generate", scheduleHandler.GenerateInstances) + r.With(middleware.RequireAdmin).Post("/shifts/publish", scheduleHandler.PublishMonth) + r.With(middleware.RequireAdmin).Post("/shifts/unpublish", scheduleHandler.UnpublishMonth) + r.With(middleware.RequireAdmin).Put("/shifts/{id}", scheduleHandler.UpdateInstance) + r.Post("/shifts/{id}/confirm", scheduleHandler.ConfirmShift) // Time off r.Get("/timeoff", timeoffHandler.List) @@ -78,8 +94,25 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { }) }) - // Serve static React app for all other routes - r.Handle("/*", http.FileServer(http.Dir(staticDir))) + // Serve static React app for all other routes, with SPA fallback + r.Handle("/*", spaHandler(staticDir)) return r } + +// spaHandler serves static files from dir, falling back to index.html for +// paths that don't match a file on disk (so client-side routing works). +func spaHandler(dir string) http.HandlerFunc { + fs := http.Dir(dir) + fileServer := http.FileServer(fs) + return func(w http.ResponseWriter, r *http.Request) { + // Try to open the requested path as a static file. + if f, err := fs.Open(r.URL.Path); err == nil { + f.Close() + fileServer.ServeHTTP(w, r) + return + } + // Not a real file — serve index.html and let React Router handle it. + http.ServeFile(w, r, dir+"/index.html") + } +} diff --git a/internal/setup/handler.go b/internal/setup/handler.go new file mode 100644 index 0000000..2cffcc5 --- /dev/null +++ b/internal/setup/handler.go @@ -0,0 +1,84 @@ +package setup + +import ( + "encoding/json" + "errors" + "net/http" + + "git.unsupervised.ca/walkies/internal/auth" + "git.unsupervised.ca/walkies/internal/respond" +) + +// TokenIssuer is the subset of auth.Service the setup handler needs. +type TokenIssuer interface { + IssueToken(volunteerID int64, role string) (string, error) +} + +type Handler struct { + store Storer + authSvc TokenIssuer +} + +func NewHandler(store *Store, authSvc *auth.Service) *Handler { + return &Handler{store: store, authSvc: authSvc} +} + +// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing. +func NewHandlerFromInterfaces(store Storer, authSvc TokenIssuer) *Handler { + return &Handler{store: store, authSvc: authSvc} +} + +// Status handles GET /api/v1/setup/status. +func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { + needs, err := h.store.NeedsSetup(r.Context()) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not check setup status") + return + } + respond.JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs}) +} + +// CreateAdmin handles POST /api/v1/setup/admin. +func (h *Handler) CreateAdmin(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Name == "" || body.Email == "" || body.Password == "" { + respond.Error(w, http.StatusBadRequest, "name, email, and password are required") + return + } + if len(body.Password) < 8 { + respond.Error(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + hashed, err := auth.HashPassword(body.Password) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not hash password") + return + } + + id, err := h.store.CreateAdmin(r.Context(), body.Name, body.Email, hashed) + if errors.Is(err, ErrSetupAlreadyDone) { + respond.Error(w, http.StatusForbidden, "setup already completed") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not create admin account") + return + } + + token, err := h.authSvc.IssueToken(id, "admin") + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not issue token") + return + } + + respond.JSON(w, http.StatusCreated, map[string]string{"token": token}) +} diff --git a/internal/setup/handler_test.go b/internal/setup/handler_test.go new file mode 100644 index 0000000..3c63942 --- /dev/null +++ b/internal/setup/handler_test.go @@ -0,0 +1,168 @@ +package setup_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.unsupervised.ca/walkies/internal/setup" +) + +// ---- fakes --------------------------------------------------------------- + +type fakeStore struct { + needsSetup bool + needsSetupErr error + createAdminID int64 + createAdminErr error +} + +func (f *fakeStore) NeedsSetup(_ context.Context) (bool, error) { + return f.needsSetup, f.needsSetupErr +} + +func (f *fakeStore) CreateAdmin(_ context.Context, _, _, _ string) (int64, error) { + return f.createAdminID, f.createAdminErr +} + +type fakeTokenIssuer struct { + token string + err error +} + +func (f *fakeTokenIssuer) IssueToken(_ int64, _ string) (string, error) { + return f.token, f.err +} + +// Compile-time interface checks. +var _ setup.Storer = (*fakeStore)(nil) +var _ setup.TokenIssuer = (*fakeTokenIssuer)(nil) + +// ---- helpers ------------------------------------------------------------- + +func do(t *testing.T, handler http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var b *bytes.Reader + if body != "" { + b = bytes.NewReader([]byte(body)) + } else { + b = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, b) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +// ---- Status tests -------------------------------------------------------- + +func TestStatus_NeedsSetup(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{needsSetup: true}, + &fakeTokenIssuer{}, + ) + w := do(t, h.Status, "GET", "/api/v1/setup/status", "") + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp map[string]bool + json.NewDecoder(w.Body).Decode(&resp) + if !resp["needs_setup"] { + t.Error("expected needs_setup=true") + } +} + +func TestStatus_SetupDone(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{needsSetup: false}, + &fakeTokenIssuer{}, + ) + w := do(t, h.Status, "GET", "/api/v1/setup/status", "") + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var resp map[string]bool + json.NewDecoder(w.Body).Decode(&resp) + if resp["needs_setup"] { + t.Error("expected needs_setup=false") + } +} + +// ---- CreateAdmin tests --------------------------------------------------- + +func TestCreateAdmin_Success(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{createAdminID: 1}, + &fakeTokenIssuer{token: "jwt-token"}, + ) + w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", + `{"name":"Admin","email":"admin@example.com","password":"supersecret"}`) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["token"] != "jwt-token" { + t.Errorf("expected token jwt-token, got %q", resp["token"]) + } +} + +func TestCreateAdmin_AlreadyDone(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{createAdminErr: setup.ErrSetupAlreadyDone}, + &fakeTokenIssuer{}, + ) + w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", + `{"name":"Admin","email":"admin@example.com","password":"supersecret"}`) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) + } +} + +func TestCreateAdmin_MissingFields(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{}, + &fakeTokenIssuer{}, + ) + + tests := []struct { + name string + body string + }{ + {"missing name", `{"email":"a@b.com","password":"supersecret"}`}, + {"missing email", `{"name":"Admin","password":"supersecret"}`}, + {"missing password", `{"name":"Admin","email":"a@b.com"}`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", tc.body) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body) + } + }) + } +} + +func TestCreateAdmin_PasswordTooShort(t *testing.T) { + h := setup.NewHandlerFromInterfaces( + &fakeStore{}, + &fakeTokenIssuer{}, + ) + w := do(t, h.CreateAdmin, "POST", "/api/v1/setup/admin", + `{"name":"Admin","email":"admin@example.com","password":"short"}`) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body) + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..30aa77e --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,68 @@ +package setup + +import ( + "context" + "database/sql" + "errors" +) + +var ErrSetupAlreadyDone = errors.New("setup already completed") + +// Storer is the interface for setup-related DB operations. +type Storer interface { + NeedsSetup(ctx context.Context) (bool, error) + CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error) +} + +type Store struct { + db *sql.DB +} + +func NewStore(db *sql.DB) *Store { + return &Store{db: db} +} + +// NeedsSetup returns true when the volunteers table has zero rows. +func (s *Store) NeedsSetup(ctx context.Context) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count) + if err != nil { + return false, err + } + return count == 0, nil +} + +// CreateAdmin atomically checks that no users exist and inserts the first admin. +func (s *Store) CreateAdmin(ctx context.Context, name, email, hashedPassword string) (int64, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM volunteers`).Scan(&count); err != nil { + return 0, err + } + if count > 0 { + return 0, ErrSetupAlreadyDone + } + + res, err := tx.ExecContext(ctx, + `INSERT INTO volunteers (name, email, password, role, active, operational_roles) VALUES (?, ?, ?, 'admin', 1, '')`, + name, email, hashedPassword, + ) + if err != nil { + return 0, err + } + + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + + if err := tx.Commit(); err != nil { + return 0, err + } + return id, nil +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e1ea7eb..6f1d606 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,10 +1,13 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import App from './App'; +import { api } from './api'; // Mock all API calls so the app renders without a backend jest.mock('./api', () => ({ api: { + getSetupStatus: jest.fn(), + createSetupAdmin: jest.fn(), listVolunteers: jest.fn().mockResolvedValue([]), listSchedules: jest.fn().mockResolvedValue([]), listTimeOff: jest.fn().mockResolvedValue([]), @@ -14,16 +17,27 @@ jest.mock('./api', () => ({ OPERATIONAL_ROLES: [], })); -test('renders login page when unauthenticated', () => { +const mockGetSetupStatus = api.getSetupStatus as jest.Mock; + +beforeEach(() => { localStorage.clear(); - render(); - expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument(); + mockGetSetupStatus.mockResolvedValue({ needs_setup: false }); }); -test('login page has email and password fields', () => { - localStorage.clear(); +test('renders login page when unauthenticated and setup done', async () => { render(); - expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: /sign in/i })).toBeInTheDocument(); +}); + +test('login page has email and password fields', async () => { + render(); + expect(await screen.findByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); }); + +test('redirects to setup when needs_setup is true', async () => { + mockGetSetupStatus.mockResolvedValue({ needs_setup: true }); + render(); + expect(await screen.findByRole('heading', { name: /initial setup/i })).toBeInTheDocument(); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index fb1f2a4..0b10b09 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'; import { AuthProvider, useAuth } from './auth'; +import { api } from './api'; import Login from './pages/Login'; import Activate from './pages/Activate'; +import Setup from './pages/Setup'; import Dashboard from './pages/Dashboard'; import Schedules from './pages/Schedules'; import TimeOff from './pages/TimeOff'; @@ -34,6 +36,7 @@ function ProtectedLayout() {
} /> + } /> } /> } /> } /> @@ -50,15 +53,57 @@ function LoginRoute() { return ; } +// Setup context lets the Setup page flip needsSetup after creating the admin. +const SetupContext = createContext<{ setNeedsSetup: (v: boolean) => void }>({ + setNeedsSetup: () => {}, +}); +export const useSetup = () => useContext(SetupContext); + +function SetupGate({ children }: { children: ReactNode }) { + const [needsSetup, setNeedsSetup] = useState(null); + + useEffect(() => { + api.getSetupStatus() + .then(r => setNeedsSetup(r.needs_setup)) + .catch(() => setNeedsSetup(false)); + }, []); + + if (needsSetup === null) return null; + + if (needsSetup) { + return ( + + + } /> + } /> + + + ); + } + + return ( + + + } /> + } /> + } /> + } /> + + + ); +} + export default function App() { return ( - - } /> - } /> - } /> - + + + } /> + } /> + } /> + + ); diff --git a/web/src/api.ts b/web/src/api.ts index f1f175d..51d176e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -22,6 +22,12 @@ async function request(method: string, path: string, body?: unknown): Promise } export const api = { + // Setup + getSetupStatus: () => + request<{ needs_setup: boolean }>('GET', '/setup/status'), + createSetupAdmin: (data: { name: string; email: string; password: string }) => + request<{ token: string }>('POST', '/setup/admin', data), + // Auth login: (email: string, password: string) => request<{ token: string }>('POST', '/auth/login', { email, password }), @@ -38,12 +44,26 @@ export const api = { resendInvite: (id: number) => request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}), - // Schedules - listSchedules: () => request('GET', '/schedules'), - createSchedule: (data: CreateScheduleInput) => request('POST', '/schedules', data), - updateSchedule: (id: number, data: Partial) => - request('PUT', `/schedules/${id}`, data), - deleteSchedule: (id: number) => request('DELETE', `/schedules/${id}`), + // Shift templates + listShiftTemplates: () => request('GET', '/shift-templates'), + createShiftTemplate: (data: CreateShiftTemplateInput) => + request('POST', '/shift-templates', data), + updateShiftTemplate: (id: number, data: Partial) => + request('PUT', `/shift-templates/${id}`, data), + deleteShiftTemplate: (id: number) => request('DELETE', `/shift-templates/${id}`), + + // Shift instances + listShifts: (year: number, month: number) => + request('GET', `/shifts?year=${year}&month=${month}`), + generateShifts: (year: number, month: number) => + request('POST', '/shifts/generate', { year, month }), + publishShifts: (year: number, month: number) => + request<{ year: number; month: number }>('POST', '/shifts/publish', { year, month }), + unpublishShifts: (year: number, month: number) => + request<{ year: number; month: number }>('POST', '/shifts/unpublish', { year, month }), + updateShift: (id: number, data: UpdateShiftInput) => + request('PUT', `/shifts/${id}`, data), + confirmShift: (id: number) => request('POST', `/shifts/${id}/confirm`, {}), // Time off listTimeOff: () => request('GET', '/timeoff'), @@ -104,23 +124,67 @@ export interface UpdateVolunteerInput { admin_notes?: string; } -export interface Schedule { +export interface TemplateRole { + id?: number; + template_id?: number; + role_name: string; + count: number; +} + +export interface ShiftTemplate { id: number; - volunteer_id: number; - title: string; - starts_at: string; - ends_at: string; - notes?: string; + name: string; + day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + roles: TemplateRole[]; + volunteer_ids: number[]; created_at: string; updated_at: string; } -export interface CreateScheduleInput { - volunteer_id?: number; - title: string; - starts_at: string; - ends_at: string; - notes?: string; +export interface CreateShiftTemplateInput { + name: string; + day_of_week: number; + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + roles?: TemplateRole[]; + volunteer_ids?: number[]; +} + +export interface InstanceVolunteer { + instance_id: number; + volunteer_id: number; + name: string; + confirmed: boolean; + confirmed_at?: string; +} + +export interface ShiftInstance { + id: number; + template_id?: number; + name: string; + date: string; // "YYYY-MM-DD" + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + status: 'draft' | 'published'; + year: number; + month: number; + volunteers: InstanceVolunteer[]; + created_at: string; + updated_at: string; +} + +export interface UpdateShiftInput { + volunteer_ids?: number[]; + min_capacity?: number; + max_capacity?: number; } export interface TimeOffRequest { @@ -155,7 +219,7 @@ export interface Notification { id: number; volunteer_id: number; message: string; - read: boolean; + is_read: boolean; created_at: string; } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index d798ead..f51bbd4 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,24 +1,25 @@ import React, { useEffect, useState } from 'react'; -import { api, CheckIn, Notification, Schedule } from '../api'; +import { api, CheckIn, Notification, ShiftInstance } from '../api'; import { useAuth } from '../auth'; export default function Dashboard() { const { volunteerID } = useAuth(); - const [schedules, setSchedules] = useState([]); + const now = new Date(); + const [schedules, setSchedules] = useState([]); const [notifications, setNotifications] = useState([]); const [activeCheckIn, setActiveCheckIn] = useState(null); const [history, setHistory] = useState([]); const [error, setError] = useState(''); useEffect(() => { - api.listSchedules().then(setSchedules).catch(() => {}); + api.listShifts(now.getFullYear(), now.getMonth() + 1).then(setSchedules).catch(() => {}); api.listNotifications().then(setNotifications).catch(() => {}); api.getHistory().then(data => { setHistory(data); const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID); setActiveCheckIn(active ?? null); }).catch(() => {}); - }, [volunteerID]); + }, [volunteerID]); // eslint-disable-line react-hooks/exhaustive-deps async function handleCheckIn() { try { @@ -43,15 +44,15 @@ export default function Dashboard() { async function handleMarkRead(id: number) { try { await api.markRead(id); - setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n)); + setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n)); } catch {} } const upcomingSchedules = schedules - .filter(s => new Date(s.starts_at) >= new Date()) + .filter(s => new Date(s.date) >= now) .slice(0, 5); - const unreadNotifications = notifications.filter(n => !n.read); + const unreadNotifications = notifications.filter(n => !n.is_read); return (
@@ -78,7 +79,7 @@ export default function Dashboard() {
    {upcomingSchedules.map(s => (
  • - {s.title} — {new Date(s.starts_at).toLocaleString()} to {new Date(s.ends_at).toLocaleString()} + {s.name} — {s.date} {s.start_time.slice(0, 5)}–{s.end_time.slice(0, 5)}
  • ))}
@@ -92,9 +93,9 @@ export default function Dashboard() { ) : (
    {notifications.map(n => ( -
  • +
  • {n.message} - {!n.read && ( + {!n.is_read && ( )}
  • diff --git a/web/src/pages/Schedules.test.tsx b/web/src/pages/Schedules.test.tsx new file mode 100644 index 0000000..ae0aaf7 --- /dev/null +++ b/web/src/pages/Schedules.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Schedules from './Schedules'; +import { api, ShiftInstance, ShiftTemplate } from '../api'; + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + api: { + listShifts: jest.fn(), + listShiftTemplates: jest.fn(), + listVolunteers: jest.fn(), + listTimeOff: jest.fn(), + generateShifts: jest.fn(), + publishShifts: jest.fn(), + unpublishShifts: jest.fn(), + updateShift: jest.fn(), + confirmShift: jest.fn(), + createShiftTemplate: jest.fn(), + updateShiftTemplate: jest.fn(), + deleteShiftTemplate: jest.fn(), + }, +})); + +jest.mock('../auth', () => ({ + useAuth: jest.fn(), +})); + +const { useAuth } = require('../auth'); + +const mockDraftInstance: ShiftInstance = { + id: 1, + name: 'Morning Shift', + date: '2026-04-06', + start_time: '09:00:00', + end_time: '12:00:00', + min_capacity: 2, + max_capacity: 5, + status: 'draft', + year: 2026, + month: 4, + volunteers: [], + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', +}; + +const mockPublishedInstance: ShiftInstance = { + ...mockDraftInstance, + id: 2, + status: 'published', + volunteers: [{ instance_id: 2, volunteer_id: 10, name: 'Alice', confirmed: false }], +}; + +const mockTemplate: ShiftTemplate = { + id: 1, + name: 'Morning Shift', + day_of_week: 1, + start_time: '09:00:00', + end_time: '12:00:00', + min_capacity: 2, + max_capacity: 5, + roles: [], + volunteer_ids: [], + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', +}; + +function renderAt(path: string) { + return render( + + + + ); +} + +describe('Schedules (volunteer view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 }); + (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); + }); + + it('renders published shifts for a volunteer', async () => { + renderAt('/schedules'); + await waitFor(() => { + expect(screen.getByText('Morning Shift')).toBeInTheDocument(); + }); + expect(screen.getByText('published')).toBeInTheDocument(); + }); + + it('does not show admin controls', async () => { + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.queryByText('Generate')).not.toBeInTheDocument(); + expect(screen.queryByText('Publish')).not.toBeInTheDocument(); + expect(screen.queryByText('Manage Templates')).not.toBeInTheDocument(); + }); +}); + +describe('Schedules (admin shifts view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); + (api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); + (api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]); + }); + + it('shows Generate and Publish buttons when drafts exist', async () => { + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.getByText('Generate')).toBeInTheDocument(); + expect(screen.getByText('Publish')).toBeInTheDocument(); + }); + + it('calls generateShifts on Generate click', async () => { + (api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Generate')); + expect(api.generateShifts).toHaveBeenCalled(); + }); + + it('calls publishShifts on Publish click', async () => { + (api.publishShifts as jest.Mock).mockResolvedValue({ year: 2026, month: 4 }); + (api.listShifts as jest.Mock) + .mockResolvedValueOnce([mockDraftInstance]) + .mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]); + + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Publish')); + expect(api.publishShifts).toHaveBeenCalled(); + }); + + it('shows Unpublish button when all shifts are published', async () => { + (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument()); + }); + + it('shows Edit button on each shift row', async () => { + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.getByText('Edit')).toBeInTheDocument(); + }); + + it('opens edit form with volunteer checkboxes when Edit is clicked', async () => { + (api.listVolunteers as jest.Mock).mockResolvedValue([ + { id: 5, name: 'Alice', active: true, operational_roles: 'Dog Shelter Volunteer', is_trainee: false }, + { id: 6, name: 'Bob', active: true, operational_roles: 'Behaviour Team', is_trainee: false }, + ]); + (api.listTimeOff as jest.Mock).mockResolvedValue([]); + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Edit')); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText(/Edit Shift/)).toBeInTheDocument(); + }); + + it('hides volunteers with approved time off on the shift date', async () => { + (api.listVolunteers as jest.Mock).mockResolvedValue([ + { id: 5, name: 'Alice', active: true, operational_roles: 'Dog Shelter Volunteer', is_trainee: false }, + { id: 6, name: 'Bob', active: true, operational_roles: 'Behaviour Team', is_trainee: false }, + ]); + (api.listTimeOff as jest.Mock).mockResolvedValue([ + { id: 1, volunteer_id: 6, starts_at: '2026-04-06T00:00:00Z', ends_at: '2026-04-07T00:00:00Z', status: 'approved' }, + ]); + renderAt('/schedules'); + await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Edit')); + await waitFor(() => expect(screen.getByText('Alice')).toBeInTheDocument()); + // Bob should not appear as a selectable volunteer + expect(screen.queryByLabelText('Bob')).not.toBeInTheDocument(); + // But should be listed as on time off + expect(screen.getByText(/On approved time off/)).toBeInTheDocument(); + expect(screen.getByText(/Bob/)).toBeInTheDocument(); + }); + + it('reads year and month from search params', async () => { + renderAt('/schedules?year=2025&month=12'); + await waitFor(() => expect(api.listShifts).toHaveBeenCalledWith(2025, 12)); + }); +}); + +describe('Schedules (admin templates view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); + (api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]); + }); + + it('renders templates at /schedules/templates', async () => { + renderAt('/schedules/templates'); + await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument()); + expect(screen.getByText('Morning Shift')).toBeInTheDocument(); + }); + + it('shows template form when New Template is clicked', async () => { + renderAt('/schedules/templates'); + await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument()); + fireEvent.click(screen.getByText('+ New Template')); + expect(screen.getByText('New Template')).toBeInTheDocument(); + }); + + it('deletes a template', async () => { + (api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined); + window.confirm = jest.fn().mockReturnValue(true); + renderAt('/schedules/templates'); + await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument()); + fireEvent.click(screen.getAllByText('Delete')[0]); + expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1); + }); +}); diff --git a/web/src/pages/Schedules.tsx b/web/src/pages/Schedules.tsx index b83ac97..3ef54c0 100644 --- a/web/src/pages/Schedules.tsx +++ b/web/src/pages/Schedules.tsx @@ -1,105 +1,635 @@ -import React, { useEffect, useState, FormEvent } from 'react'; -import { api, Schedule } from '../api'; +import React, { useEffect, useState, FormEvent, useCallback } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { + api, + ShiftTemplate, + ShiftInstance, + Volunteer, + TimeOffRequest, + CreateShiftTemplateInput, + TemplateRole, + OPERATIONAL_ROLES, +} from '../api'; import { useAuth } from '../auth'; +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +function currentYearMonth() { + const now = new Date(); + return { year: now.getFullYear(), month: now.getMonth() + 1 }; +} + +// --------------------------------------------------------------------------- +// Template form +// --------------------------------------------------------------------------- + +function blankTemplate(): CreateShiftTemplateInput { + return { + name: '', + day_of_week: 1, + start_time: '09:00:00', + end_time: '17:00:00', + min_capacity: 1, + max_capacity: 5, + roles: [], + volunteer_ids: [], + }; +} + +interface TemplateFormProps { + initial?: Partial; + onSave: (data: CreateShiftTemplateInput) => Promise; + onCancel: () => void; + title: string; +} + +function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) { + const [form, setForm] = useState({ ...blankTemplate(), ...initial }); + const [roleRow, setRoleRow] = useState({ role_name: '', count: 1 }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + await onSave(form); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + function addRole() { + if (!roleRow.role_name) return; + setForm(f => ({ ...f, roles: [...(f.roles ?? []), { ...roleRow }] })); + setRoleRow({ role_name: '', count: 1 }); + } + + function removeRole(idx: number) { + setForm(f => ({ ...f, roles: (f.roles ?? []).filter((_, i) => i !== idx) })); + } + + return ( +
    +

    {title}

    + {error &&

    {error}

    } + + + + + + + +
    + Role Requirements + {(form.roles ?? []).map((r, i) => ( +
    + {r.count}× {r.role_name} + +
    + ))} +
    + + + +
    +
    + +
    + + +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Shift instance edit form +// --------------------------------------------------------------------------- + +interface ShiftEditFormProps { + instance: ShiftInstance; + templateId: number | undefined; + onSave: (inst: ShiftInstance) => void; + onCancel: () => void; +} + +function ShiftEditForm({ instance, templateId, onSave, onCancel }: ShiftEditFormProps) { + const [volunteers, setVolunteers] = useState([]); + const [templateRoles, setTemplateRoles] = useState([]); + const [unavailableIds, setUnavailableIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>( + new Set(instance.volunteers.map(v => v.volunteer_id)) + ); + const [minCap, setMinCap] = useState(instance.min_capacity); + const [maxCap, setMaxCap] = useState(instance.max_capacity); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loads: Promise[] = [ + api.listVolunteers().then((vols) => setVolunteers(vols as Volunteer[])), + api.listTimeOff().then((requests: TimeOffRequest[]) => { + const shiftDate = new Date(instance.date + 'T00:00:00'); + const off = new Set(); + for (const r of requests) { + if (r.status !== 'approved') continue; + const start = new Date(r.starts_at); + const end = new Date(r.ends_at); + // Shift date falls within the approved time-off range + if (shiftDate >= new Date(start.toDateString()) && shiftDate <= new Date(end.toDateString())) { + off.add(r.volunteer_id); + } + } + setUnavailableIds(off); + }), + ]; + if (templateId) { + loads.push( + api.listShiftTemplates().then((tmpls) => { + const tmpl = tmpls.find(t => t.id === templateId); + if (tmpl) setTemplateRoles(tmpl.roles ?? []); + }) + ); + } + Promise.all(loads).finally(() => setLoading(false)); + }, [templateId, instance.date]); + + function toggleVolunteer(id: number) { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const updated = await api.updateShift(instance.id, { + volunteer_ids: Array.from(selectedIds), + min_capacity: minCap, + max_capacity: maxCap, + }); + onSave(updated); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + // Group active, available volunteers by their operational roles for display + const activeVolunteers = volunteers.filter(v => v.active && !unavailableIds.has(v.id)); + const onTimeOff = volunteers.filter(v => v.active && unavailableIds.has(v.id)); + + // Build a map of role → volunteers who hold that role + const byRole = new Map(); + for (const v of activeVolunteers) { + const roles = v.operational_roles + ? v.operational_roles.split(',').map(r => r.trim()).filter(Boolean) + : []; + if (roles.length === 0) { + const list = byRole.get('Unassigned') ?? []; + list.push(v); + byRole.set('Unassigned', list); + } else { + for (const r of roles) { + const list = byRole.get(r) ?? []; + list.push(v); + byRole.set(r, list); + } + } + } + + // Order sections: template roles first, then any remaining + const roleOrder: string[] = []; + for (const tr of templateRoles) { + if (!roleOrder.includes(tr.role_name)) roleOrder.push(tr.role_name); + } + byRole.forEach((_, key) => { + if (!roleOrder.includes(key)) roleOrder.push(key); + }); + + if (loading) return

    Loading volunteers…

    ; + + return ( +
    +

    Edit Shift: {instance.name} — {instance.date}

    + {error &&

    {error}

    } + + {templateRoles.length > 0 && ( +

    + Required:{' '} + {templateRoles.map(r => `${r.count}× ${r.role_name}`).join(', ')} +

    + )} + +
    + Volunteers ({selectedIds.size} selected) + {roleOrder.map(roleName => { + const vols = byRole.get(roleName); + if (!vols || vols.length === 0) return null; + const required = templateRoles.find(r => r.role_name === roleName); + const selectedInRole = vols.filter(v => selectedIds.has(v.id)).length; + return ( +
    + + {roleName} + {required && ( + = required.count ? '#2a7' : '#c55' }}> + {' '}({selectedInRole}/{required.count}) + + )} + +
    + {vols.map(v => ( + + ))} +
    +
    + ); + })} +
    + + {onTimeOff.length > 0 && ( +

    + On approved time off:{' '} + {onTimeOff.map(v => v.name).join(', ')} +

    + )} + +
    + + +
    + +
    + + +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + export default function Schedules() { const { role } = useAuth(); - const [schedules, setSchedules] = useState([]); + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + + // Derive view from URL path + const isTemplatesView = location.pathname === '/schedules/templates'; + + // Derive year/month from search params (shifts view only) + const init = currentYearMonth(); + const year = Number(searchParams.get('year')) || init.year; + const month = Number(searchParams.get('month')) || init.month; + + // Data + const [instances, setInstances] = useState([]); + const [templates, setTemplates] = useState([]); const [error, setError] = useState(''); - const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ title: '', starts_at: '', ends_at: '', notes: '' }); + + // UI state + const [showTemplateForm, setShowTemplateForm] = useState(false); + const [editingTemplate, setEditingTemplate] = useState(null); + const [editingInstance, setEditingInstance] = useState(null); + + // Navigation helpers + const goToShifts = useCallback((y: number, m: number) => { + navigate(`/schedules?year=${y}&month=${m}`); + }, [navigate]); + + const goToTemplates = useCallback(() => { + navigate('/schedules/templates'); + }, [navigate]); useEffect(() => { - api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.')); - }, []); + if (isTemplatesView) { + api.listShiftTemplates() + .then(setTemplates) + .catch(() => setError('Could not load templates.')); + } else { + api.listShifts(year, month) + .then(setInstances) + .catch(() => setError('Could not load shifts.')); + } + }, [isTemplatesView, year, month]); - async function handleCreate(e: FormEvent) { - e.preventDefault(); + function prevMonth() { + const m = month === 1 ? 12 : month - 1; + const y = month === 1 ? year - 1 : year; + goToShifts(y, m); + } + + function nextMonth() { + const m = month === 12 ? 1 : month + 1; + const y = month === 12 ? year + 1 : year; + goToShifts(y, m); + } + + async function handleGenerate() { setError(''); try { - const sc = await api.createSchedule(form); - setSchedules(prev => [...prev, sc]); - setForm({ title: '', starts_at: '', ends_at: '', notes: '' }); - setShowForm(false); + const newInstances = await api.generateShifts(year, month); + setInstances(newInstances); } catch (err: any) { setError(err.message); } } - async function handleDelete(id: number) { - if (!window.confirm('Delete this schedule?')) return; + async function handlePublish() { + setError(''); try { - await api.deleteSchedule(id); - setSchedules(prev => prev.filter(s => s.id !== id)); + await api.publishShifts(year, month); + const updated = await api.listShifts(year, month); + setInstances(updated); } catch (err: any) { setError(err.message); } } + async function handleUnpublish() { + if (!window.confirm(`Unpublish the ${MONTH_NAMES[month - 1]} ${year} schedule?`)) return; + setError(''); + try { + await api.unpublishShifts(year, month); + const updated = await api.listShifts(year, month); + setInstances(updated); + } catch (err: any) { + setError(err.message); + } + } + + async function handleConfirm(id: number) { + setError(''); + try { + await api.confirmShift(id); + const updated = await api.listShifts(year, month); + setInstances(updated); + } catch (err: any) { + setError(err.message); + } + } + + async function handleCreateTemplate(data: CreateShiftTemplateInput) { + const t = await api.createShiftTemplate(data); + setTemplates(prev => [...prev, t]); + setShowTemplateForm(false); + } + + async function handleUpdateTemplate(data: CreateShiftTemplateInput) { + if (!editingTemplate) return; + const t = await api.updateShiftTemplate(editingTemplate.id, data); + setTemplates(prev => prev.map(x => (x.id === t.id ? t : x))); + setEditingTemplate(null); + } + + async function handleDeleteTemplate(id: number) { + if (!window.confirm('Delete this template? Existing shifts will not be affected.')) return; + setError(''); + try { + await api.deleteShiftTemplate(id); + setTemplates(prev => prev.filter(t => t.id !== id)); + } catch (err: any) { + setError(err.message); + } + } + + function handleInstanceSaved(updated: ShiftInstance) { + setInstances(prev => prev.map(i => (i.id === updated.id ? updated : i))); + setEditingInstance(null); + } + + const allPublished = instances.length > 0 && instances.every(i => i.status === 'published'); + const hasDraft = instances.some(i => i.status === 'draft'); + return (

    Schedules

    {role === 'admin' && ( - +
    + +
    )}
    + {error &&

    {error}

    } - {showForm && ( -
    -

    New Shift

    - - - -