Compare commits

...

10 Commits

Author SHA1 Message Date
6575ce8f44 Filter out volunteers with approved time off from shift edit picker
All checks were successful
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Successful in 39s
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 40s
The edit form now fetches approved time-off requests and excludes
volunteers whose time off overlaps the shift date from the
selectable list. Unavailable volunteers are shown below the picker
in a "On approved time off" note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:59:05 -03:00
07bf4c2112 Replace volunteer ID input with checkbox picker on shift edit
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 41s
CI / Go tests & lint (pull_request) Successful in 9s
CI / Frontend tests & type-check (pull_request) Successful in 42s
The edit form now loads the volunteer list and groups them by
operational role. Template role requirements are shown with a
count indicator (selected/required) that turns green when filled.
Volunteers are selected via checkboxes instead of typing IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:56:20 -03:00
9473ce24bc Persist schedules view and month in the URL
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 41s
CI / Go tests & lint (pull_request) Successful in 10s
CI / Frontend tests & type-check (pull_request) Successful in 41s
- /schedules?year=2026&month=4 for the shifts view
- /schedules/templates for the templates view
- Month navigation updates search params via useNavigate
- Reloading or sharing a URL restores the exact view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:34:30 -03:00
c78a35377a Fix SPA routing: serve index.html for non-file paths
All checks were successful
CI / Go tests & lint (push) Successful in 8s
CI / Frontend tests & type-check (push) Successful in 39s
CI / Go tests & lint (pull_request) Successful in 9s
CI / Frontend tests & type-check (pull_request) Successful in 40s
Replace bare http.FileServer with an SPA-aware handler that tries to
serve the requested static file and falls back to index.html when
the path doesn't match a real file. This lets React Router handle
client-side routes like /schedules on page reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:10:07 -03:00
0af53c9b55 Fix null volunteers array in shift instance JSON responses
All checks were successful
CI / Go tests & lint (push) Successful in 9s
CI / Frontend tests & type-check (push) Successful in 44s
CI / Go tests & lint (pull_request) Successful in 9s
CI / Frontend tests & type-check (pull_request) Successful in 44s
Initialize slice helpers with make() instead of var declaration so
empty results serialize as [] instead of null in JSON, fixing the
frontend TypeError on the schedules page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:08:37 -03:00
3900dff5a1 Implement Issue #11: Initial admin account setup on first deploy
When the database has no users, the UI redirects to /setup and prompts
for creation of the first admin account. The setup endpoints are
self-disabling — once any user exists, POST /setup/admin returns 403
and the frontend redirects /setup back to /login. The backend uses an
atomic transaction to prevent race conditions on concurrent requests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 19:02:16 -03:00
f29d9669f8 Add a skill for monitoring gitea actions
All checks were successful
CI / Go tests & lint (push) Successful in 8s
CI / Frontend tests & type-check (push) Successful in 42s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Successful in 38s
2026-04-08 17:32:23 -03:00
e82a39f2e4 Fix build and run issues
All checks were successful
CI / Go tests & lint (push) Successful in 8s
CI / Frontend tests & type-check (push) Successful in 40s
CI / Go tests & lint (pull_request) Successful in 7s
CI / Frontend tests & type-check (pull_request) Successful in 35s
2026-04-08 14:58:58 -03:00
9905234b34 Fix Dashboard.tsx: update Schedule refs to ShiftInstance
All checks were successful
CI / Go tests & lint (push) Successful in 8s
CI / Frontend tests & type-check (push) Successful in 38s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Successful in 38s
Replace removed Schedule type and api.listSchedules() with
ShiftInstance and api.listShifts() after the schedule rewrite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:48:47 -03:00
fc88b8f005 Implement Issue #2: Scheduling & Publishing
Some checks failed
CI / Go tests & lint (push) Successful in 1m42s
CI / Frontend tests & type-check (push) Failing after 1m38s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 32s
Replaces stub schedule CRUD with full shift template + instance system.

- DB: add shift_templates, shift_template_roles, shift_template_volunteers,
  shift_instances, shift_instance_volunteers tables
- schedule package: ShiftTemplate and ShiftInstance models with store
  (generate, publish/unpublish, per-instance edits, volunteer confirmation)
- API: shift-templates CRUD + shifts generate/publish/unpublish/update/confirm
- Notifications sent on publish (FR-S04), unpublish (FR-S05), instance edit
  (FR-S09), and volunteer added mid-month (FR-S10)
- Frontend: Schedules page with month navigation, template management,
  publish/unpublish controls, and per-shift edit/confirm
- Tests: Go handler tests (14 cases) + React tests (11 cases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:40:41 -03:00
21 changed files with 3216 additions and 282 deletions

View File

@@ -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=<id from above>
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.

View File

@@ -2,7 +2,7 @@
FROM node:22-alpine AS frontend FROM node:22-alpine AS frontend
WORKDIR /app/web WORKDIR /app/web
COPY web/package*.json ./ COPY web/package*.json ./
RUN npm ci RUN npm install
COPY web/ ./ COPY web/ ./
RUN npm run build RUN npm run build

View File

@@ -104,16 +104,16 @@ tasks:
cmd: docker build -t walkies . cmd: docker build -t walkies .
docker:up: docker:up:
desc: Build and start with docker-compose desc: Build and start with docker compose
cmd: docker-compose up --build cmd: docker compose up --build
docker:down: docker:down:
desc: Stop docker-compose services desc: Stop docker compose services
cmd: docker-compose down cmd: docker compose down
docker:logs: docker:logs:
desc: Tail docker-compose logs desc: Tail docker compose logs
cmd: docker-compose logs -f cmd: docker compose logs -f
# ── Utilities ──────────────────────────────────────────────────────────────── # ── Utilities ────────────────────────────────────────────────────────────────

View File

@@ -3,9 +3,10 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
_ "github.com/go-sql-driver/mysql" "github.com/go-sql-driver/mysql"
) )
func Open(dsn string) (*sql.DB, error) { 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 { func Migrate(ctx context.Context, db *sql.DB) error {
for _, stmt := range statements { for _, stmt := range statements {
if _, err := db.ExecContext(ctx, stmt); err != nil { 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) return fmt.Errorf("migrate: %w", err)
} }
} }

View File

@@ -10,7 +10,7 @@ var statements = []string{
active TINYINT NOT NULL DEFAULT 1, active TINYINT NOT NULL DEFAULT 1,
is_trainee TINYINT NOT NULL DEFAULT 0, is_trainee TINYINT NOT NULL DEFAULT 0,
phone VARCHAR(20) NULL, phone VARCHAR(20) NULL,
operational_roles TEXT NOT NULL DEFAULT '', operational_roles TEXT NOT NULL,
notification_preference VARCHAR(50) NOT NULL DEFAULT 'email', notification_preference VARCHAR(50) NOT NULL DEFAULT 'email',
admin_notes TEXT NULL, admin_notes TEXT NULL,
last_login DATETIME NULL, last_login DATETIME NULL,
@@ -19,15 +19,15 @@ var statements = []string{
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
// Additive column migrations for existing deployments // Additive column migrations for existing deployments (duplicates ignored at runtime)
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`, `ALTER TABLE volunteers ADD COLUMN is_trainee TINYINT NOT NULL DEFAULT 0`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`, `ALTER TABLE volunteers ADD COLUMN phone VARCHAR(20) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volunteers ADD COLUMN operational_roles TEXT NOT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`, `ALTER TABLE volunteers ADD COLUMN 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 admin_notes TEXT NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`, `ALTER TABLE volunteers ADD COLUMN last_login DATETIME NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`, `ALTER TABLE volunteers ADD COLUMN invite_token VARCHAR(255) NULL`,
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`, `ALTER TABLE volunteers ADD COLUMN invite_expires_at DATETIME NULL`,
`CREATE TABLE IF NOT EXISTS schedules ( `CREATE TABLE IF NOT EXISTS schedules (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL, volunteer_id INT NOT NULL,
@@ -72,10 +72,67 @@ var statements = []string{
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
volunteer_id INT NOT NULL, volunteer_id INT NOT NULL,
message TEXT 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
INDEX idx_volunteer_id (volunteer_id), 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`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
} }

View File

@@ -14,7 +14,7 @@ type Notification struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
Message string `json:"message"` Message string `json:"message"`
Read bool `json:"read"` Read bool `json:"is_read"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -42,7 +42,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
n := &Notification{} n := &Notification{}
var createdAt string var createdAt string
err := s.db.QueryRowContext(ctx, 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) ).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound 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) { func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) {
rows, err := s.db.QueryContext(ctx, 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, volunteerID,
) )
if err != nil { if err != nil {
@@ -77,9 +77,15 @@ func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Noti
return notifications, rows.Err() 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) { func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
result, err := s.db.ExecContext(ctx, 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, id, volunteerID,
) )
if err != nil { if err != nil {

View File

@@ -1,99 +1,329 @@
package schedule package schedule
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.unsupervised.ca/walkies/internal/respond" "git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5" "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 { type Handler struct {
store *Store store Storer
notifier Notifier
} }
func NewHandler(store *Store) *Handler { // Storer is the interface the Handler depends on.
return &Handler{store: store} 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 NewHandler(store *Store, notifier Notifier) *Handler {
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { return &Handler{store: store, notifier: notifier}
claims := middleware.ClaimsFromContext(r.Context()) }
volunteerID := int64(0)
if claims.Role != "admin" { // NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
volunteerID = claims.VolunteerID func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
} return &Handler{store: store, notifier: notifier}
schedules, err := h.store.List(r.Context(), volunteerID) }
// ---------------------------------------------------------------------------
// 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 { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list schedules") respond.Error(w, http.StatusInternalServerError, "could not list templates")
return return
} }
if schedules == nil { if templates == nil {
schedules = []Schedule{} templates = []ShiftTemplate{}
} }
respond.JSON(w, http.StatusOK, schedules) respond.JSON(w, http.StatusOK, templates)
} }
// POST /api/v1/schedules // POST /api/v1/shift-templates
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context()) var in CreateTemplateInput
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
if claims.Role != "admin" { if in.Name == "" || in.StartTime == "" || in.EndTime == "" {
in.VolunteerID = claims.VolunteerID respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
}
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
return 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 { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create schedule") respond.Error(w, http.StatusInternalServerError, "could not create template")
return return
} }
respond.JSON(w, http.StatusCreated, sc) respond.JSON(w, http.StatusCreated, t)
} }
// PUT /api/v1/schedules/{id} // PUT /api/v1/shift-templates/{id}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil { if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id") respond.Error(w, http.StatusBadRequest, "invalid id")
return return
} }
var in UpdateInput var in UpdateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil { if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body") respond.Error(w, http.StatusBadRequest, "invalid request body")
return return
} }
sc, err := h.store.Update(r.Context(), id, in) t, err := h.store.UpdateTemplate(r.Context(), id, in)
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "schedule not found") respond.Error(w, http.StatusNotFound, "template not found")
return return
} }
if err != nil { if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update schedule") respond.Error(w, http.StatusInternalServerError, "could not update template")
return return
} }
respond.JSON(w, http.StatusOK, sc) respond.JSON(w, http.StatusOK, t)
} }
// DELETE /api/v1/schedules/{id} // DELETE /api/v1/shift-templates/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil { if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id") respond.Error(w, http.StatusBadRequest, "invalid id")
return return
} }
if err := h.store.Delete(r.Context(), id); err != nil { if err := h.store.DeleteTemplate(r.Context(), id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete schedule") respond.Error(w, http.StatusInternalServerError, "could not delete template")
return return
} }
w.WriteHeader(http.StatusNoContent) 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
}

View File

@@ -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)

View File

@@ -8,35 +8,96 @@ import (
"time" "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 { // ---------------------------------------------------------------------------
// Models
// ---------------------------------------------------------------------------
type ShiftTemplate struct {
ID int64 `json:"id"` ID int64 `json:"id"`
VolunteerID int64 `json:"volunteer_id"` Name string `json:"name"`
Title string `json:"title"` DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat
StartsAt time.Time `json:"starts_at"` StartTime string `json:"start_time"` // "HH:MM:SS"
EndsAt time.Time `json:"ends_at"` EndTime string `json:"end_time"`
Notes string `json:"notes,omitempty"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
type CreateInput struct { type TemplateRole struct {
ID int64 `json:"id"`
TemplateID int64 `json:"template_id"`
RoleName string `json:"role_name"`
Count int `json:"count"`
}
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"`
}
type InstanceVolunteer struct {
InstanceID int64 `json:"instance_id"`
VolunteerID int64 `json:"volunteer_id"` VolunteerID int64 `json:"volunteer_id"`
Title string `json:"title"` Name string `json:"name"`
StartsAt string `json:"starts_at"` Confirmed bool `json:"confirmed"`
EndsAt string `json:"ends_at"` ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
Notes string `json:"notes"`
} }
type UpdateInput struct { // ---------------------------------------------------------------------------
Title *string `json:"title"` // Input types
StartsAt *string `json:"starts_at"` // ---------------------------------------------------------------------------
EndsAt *string `json:"ends_at"`
Notes *string `json:"notes"` 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"`
} }
const timeLayout = "2006-01-02T15:04:05Z" 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 { type Store struct {
db *sql.DB db *sql.DB
@@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) { // ---------------------------------------------------------------------------
res, err := s.db.ExecContext(ctx, // Template operations
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`, // ---------------------------------------------------------------------------
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
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 { if err != nil {
return nil, fmt.Errorf("insert schedule: %w", err) return nil, fmt.Errorf("insert template: %w", err)
} }
id, _ := res.LastInsertId() 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) { func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
sc := &Schedule{} t := &ShiftTemplate{}
var startsAt, endsAt, createdAt, updatedAt string var createdAt, updatedAt string
var notes sql.NullString
err := s.db.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id, `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, &notes, &createdAt, &updatedAt) 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
if err != nil { 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) t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) roles, err := s.templateRoles(ctx, id)
if notes.Valid { if err != nil {
sc.Notes = notes.String return nil, err
} }
return sc, nil t.Roles = roles
vids, err := s.templateVolunteerIDs(ctx, id)
if err != nil {
return nil, err
}
t.VolunteerIDs = vids
return t, nil
} }
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) { func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) {
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules` rows, err := s.db.QueryContext(ctx,
args := []any{} `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
if volunteerID > 0 { FROM shift_templates ORDER BY day_of_week, start_time`)
query += ` WHERE volunteer_id = ?`
args = append(args, volunteerID)
}
query += ` ORDER BY starts_at`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("list schedules: %w", err) return nil, fmt.Errorf("list templates: %w", err)
} }
defer rows.Close() defer rows.Close()
var schedules []Schedule var templates []ShiftTemplate
for rows.Next() { for rows.Next() {
var sc Schedule var t ShiftTemplate
var startsAt, endsAt, createdAt, updatedAt string var createdAt, updatedAt string
var notes sql.NullString if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, &notes, &createdAt, &updatedAt); err != nil { &t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil {
return nil, err return nil, err
} }
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) templates = append(templates, t)
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if notes.Valid {
sc.Notes = notes.String
} }
schedules = append(schedules, sc) if err := rows.Err(); err != nil {
return nil, err
} }
return schedules, rows.Err()
}
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) { // Load roles and volunteers for each template
sc, err := s.GetByID(ctx, id) for i := range templates {
roles, err := s.templateRoles(ctx, templates[i].ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
title := sc.Title templates[i].Roles = roles
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
notes := sc.Notes
if in.Title != nil { vids, err := s.templateVolunteerIDs(ctx, templates[i].ID)
title = *in.Title if err != nil {
return nil, err
} }
if in.StartsAt != nil { templates[i].VolunteerIDs = vids
startsAt = *in.StartsAt
} }
if in.EndsAt != nil { return templates, nil
endsAt = *in.EndsAt }
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
} }
if in.Notes != nil {
notes = *in.Notes 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
} }
_, err = s.db.ExecContext(ctx, if in.DayOfWeek != nil {
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`, dow = *in.DayOfWeek
title, startsAt, endsAt, notes, id, }
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 { 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 { func (s *Store) DeleteTemplate(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id) _, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id)
return err 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()
}

View File

@@ -12,6 +12,7 @@ import (
"git.unsupervised.ca/walkies/internal/notification" "git.unsupervised.ca/walkies/internal/notification"
"git.unsupervised.ca/walkies/internal/schedule" "git.unsupervised.ca/walkies/internal/schedule"
"git.unsupervised.ca/walkies/internal/server/middleware" "git.unsupervised.ca/walkies/internal/server/middleware"
"git.unsupervised.ca/walkies/internal/setup"
"git.unsupervised.ca/walkies/internal/timeoff" "git.unsupervised.ca/walkies/internal/timeoff"
"git.unsupervised.ca/walkies/internal/volunteer" "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) volunteerStore := volunteer.NewStore(db)
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc) volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
notificationStore := notification.NewStore(db)
notificationHandler := notification.NewHandler(notificationStore)
scheduleStore := schedule.NewStore(db) scheduleStore := schedule.NewStore(db)
scheduleHandler := schedule.NewHandler(scheduleStore) scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore)
timeoffStore := timeoff.NewStore(db) timeoffStore := timeoff.NewStore(db)
timeoffHandler := timeoff.NewHandler(timeoffStore) timeoffHandler := timeoff.NewHandler(timeoffStore)
@@ -31,8 +35,8 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
checkinStore := checkin.NewStore(db) checkinStore := checkin.NewStore(db)
checkinHandler := checkin.NewHandler(checkinStore) checkinHandler := checkin.NewHandler(checkinStore)
notificationStore := notification.NewStore(db) setupStore := setup.NewStore(db)
notificationHandler := notification.NewHandler(notificationStore) setupHandler := setup.NewHandler(setupStore, authSvc)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(chimiddleware.Logger) 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/login", volunteerHandler.Login)
r.Post("/auth/activate", volunteerHandler.Activate) 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 // Protected routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc)) 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.Put("/volunteers/{id}", volunteerHandler.Update)
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite) r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
// Schedules // Shift templates (admin only)
r.Get("/schedules", scheduleHandler.List) r.Get("/shift-templates", scheduleHandler.ListTemplates)
r.Post("/schedules", scheduleHandler.Create) r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate)
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update) r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate)
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete) 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 // Time off
r.Get("/timeoff", timeoffHandler.List) 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 // Serve static React app for all other routes, with SPA fallback
r.Handle("/*", http.FileServer(http.Dir(staticDir))) r.Handle("/*", spaHandler(staticDir))
return r 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")
}
}

84
internal/setup/handler.go Normal file
View File

@@ -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})
}

View File

@@ -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)
}
}

68
internal/setup/setup.go Normal file
View File

@@ -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
}

View File

@@ -1,10 +1,13 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import App from './App'; import App from './App';
import { api } from './api';
// Mock all API calls so the app renders without a backend // Mock all API calls so the app renders without a backend
jest.mock('./api', () => ({ jest.mock('./api', () => ({
api: { api: {
getSetupStatus: jest.fn(),
createSetupAdmin: jest.fn(),
listVolunteers: jest.fn().mockResolvedValue([]), listVolunteers: jest.fn().mockResolvedValue([]),
listSchedules: jest.fn().mockResolvedValue([]), listSchedules: jest.fn().mockResolvedValue([]),
listTimeOff: jest.fn().mockResolvedValue([]), listTimeOff: jest.fn().mockResolvedValue([]),
@@ -14,16 +17,27 @@ jest.mock('./api', () => ({
OPERATIONAL_ROLES: [], OPERATIONAL_ROLES: [],
})); }));
test('renders login page when unauthenticated', () => { const mockGetSetupStatus = api.getSetupStatus as jest.Mock;
beforeEach(() => {
localStorage.clear(); localStorage.clear();
render(<App />); mockGetSetupStatus.mockResolvedValue({ needs_setup: false });
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
}); });
test('login page has email and password fields', () => { test('renders login page when unauthenticated and setup done', async () => {
localStorage.clear();
render(<App />); render(<App />);
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(<App />);
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/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(<App />);
expect(await screen.findByRole('heading', { name: /initial setup/i })).toBeInTheDocument();
});

View File

@@ -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 { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './auth'; import { AuthProvider, useAuth } from './auth';
import { api } from './api';
import Login from './pages/Login'; import Login from './pages/Login';
import Activate from './pages/Activate'; import Activate from './pages/Activate';
import Setup from './pages/Setup';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Schedules from './pages/Schedules'; import Schedules from './pages/Schedules';
import TimeOff from './pages/TimeOff'; import TimeOff from './pages/TimeOff';
@@ -34,6 +36,7 @@ function ProtectedLayout() {
<main> <main>
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/schedules/templates" element={<Schedules />} />
<Route path="/schedules" element={<Schedules />} /> <Route path="/schedules" element={<Schedules />} />
<Route path="/timeoff" element={<TimeOff />} /> <Route path="/timeoff" element={<TimeOff />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
@@ -50,15 +53,57 @@ function LoginRoute() {
return <Login />; return <Login />;
} }
// 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<boolean | null>(null);
useEffect(() => {
api.getSetupStatus()
.then(r => setNeedsSetup(r.needs_setup))
.catch(() => setNeedsSetup(false));
}, []);
if (needsSetup === null) return null;
if (needsSetup) {
return (
<SetupContext.Provider value={{ setNeedsSetup }}>
<Routes>
<Route path="/setup" element={<Setup />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
</SetupContext.Provider>
);
}
return (
<SetupContext.Provider value={{ setNeedsSetup }}>
<Routes>
<Route path="/setup" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginRoute />} />
<Route path="/activate" element={<Activate />} />
<Route path="/*" element={<ProtectedLayout />} />
</Routes>
</SetupContext.Provider>
);
}
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<SetupGate>
<Routes> <Routes>
<Route path="/login" element={<LoginRoute />} /> <Route path="/login" element={<LoginRoute />} />
<Route path="/activate" element={<Activate />} /> <Route path="/activate" element={<Activate />} />
<Route path="/*" element={<ProtectedLayout />} /> <Route path="/*" element={<ProtectedLayout />} />
</Routes> </Routes>
</SetupGate>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
); );

View File

@@ -22,6 +22,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
} }
export const api = { 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 // Auth
login: (email: string, password: string) => login: (email: string, password: string) =>
request<{ token: string }>('POST', '/auth/login', { email, password }), request<{ token: string }>('POST', '/auth/login', { email, password }),
@@ -38,12 +44,26 @@ export const api = {
resendInvite: (id: number) => resendInvite: (id: number) =>
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}), request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
// Schedules // Shift templates
listSchedules: () => request<Schedule[]>('GET', '/schedules'), listShiftTemplates: () => request<ShiftTemplate[]>('GET', '/shift-templates'),
createSchedule: (data: CreateScheduleInput) => request<Schedule>('POST', '/schedules', data), createShiftTemplate: (data: CreateShiftTemplateInput) =>
updateSchedule: (id: number, data: Partial<CreateScheduleInput>) => request<ShiftTemplate>('POST', '/shift-templates', data),
request<Schedule>('PUT', `/schedules/${id}`, data), updateShiftTemplate: (id: number, data: Partial<CreateShiftTemplateInput>) =>
deleteSchedule: (id: number) => request<void>('DELETE', `/schedules/${id}`), request<ShiftTemplate>('PUT', `/shift-templates/${id}`, data),
deleteShiftTemplate: (id: number) => request<void>('DELETE', `/shift-templates/${id}`),
// Shift instances
listShifts: (year: number, month: number) =>
request<ShiftInstance[]>('GET', `/shifts?year=${year}&month=${month}`),
generateShifts: (year: number, month: number) =>
request<ShiftInstance[]>('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<ShiftInstance>('PUT', `/shifts/${id}`, data),
confirmShift: (id: number) => request<void>('POST', `/shifts/${id}/confirm`, {}),
// Time off // Time off
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'), listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
@@ -104,23 +124,67 @@ export interface UpdateVolunteerInput {
admin_notes?: string; admin_notes?: string;
} }
export interface Schedule { export interface TemplateRole {
id?: number;
template_id?: number;
role_name: string;
count: number;
}
export interface ShiftTemplate {
id: number; id: number;
volunteer_id: number; name: string;
title: string; day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat
starts_at: string; start_time: string;
ends_at: string; end_time: string;
notes?: string; min_capacity: number;
max_capacity: number;
roles: TemplateRole[];
volunteer_ids: number[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface CreateScheduleInput { export interface CreateShiftTemplateInput {
volunteer_id?: number; name: string;
title: string; day_of_week: number;
starts_at: string; start_time: string;
ends_at: string; end_time: string;
notes?: 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 { export interface TimeOffRequest {
@@ -155,7 +219,7 @@ export interface Notification {
id: number; id: number;
volunteer_id: number; volunteer_id: number;
message: string; message: string;
read: boolean; is_read: boolean;
created_at: string; created_at: string;
} }

View File

@@ -1,24 +1,25 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { api, CheckIn, Notification, Schedule } from '../api'; import { api, CheckIn, Notification, ShiftInstance } from '../api';
import { useAuth } from '../auth'; import { useAuth } from '../auth';
export default function Dashboard() { export default function Dashboard() {
const { volunteerID } = useAuth(); const { volunteerID } = useAuth();
const [schedules, setSchedules] = useState<Schedule[]>([]); const now = new Date();
const [schedules, setSchedules] = useState<ShiftInstance[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [activeCheckIn, setActiveCheckIn] = useState<CheckIn | null>(null); const [activeCheckIn, setActiveCheckIn] = useState<CheckIn | null>(null);
const [history, setHistory] = useState<CheckIn[]>([]); const [history, setHistory] = useState<CheckIn[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
api.listSchedules().then(setSchedules).catch(() => {}); api.listShifts(now.getFullYear(), now.getMonth() + 1).then(setSchedules).catch(() => {});
api.listNotifications().then(setNotifications).catch(() => {}); api.listNotifications().then(setNotifications).catch(() => {});
api.getHistory().then(data => { api.getHistory().then(data => {
setHistory(data); setHistory(data);
const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID); const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID);
setActiveCheckIn(active ?? null); setActiveCheckIn(active ?? null);
}).catch(() => {}); }).catch(() => {});
}, [volunteerID]); }, [volunteerID]); // eslint-disable-line react-hooks/exhaustive-deps
async function handleCheckIn() { async function handleCheckIn() {
try { try {
@@ -43,15 +44,15 @@ export default function Dashboard() {
async function handleMarkRead(id: number) { async function handleMarkRead(id: number) {
try { try {
await api.markRead(id); 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 {} } catch {}
} }
const upcomingSchedules = schedules const upcomingSchedules = schedules
.filter(s => new Date(s.starts_at) >= new Date()) .filter(s => new Date(s.date) >= now)
.slice(0, 5); .slice(0, 5);
const unreadNotifications = notifications.filter(n => !n.read); const unreadNotifications = notifications.filter(n => !n.is_read);
return ( return (
<div className="page"> <div className="page">
@@ -78,7 +79,7 @@ export default function Dashboard() {
<ul> <ul>
{upcomingSchedules.map(s => ( {upcomingSchedules.map(s => (
<li key={s.id}> <li key={s.id}>
<strong>{s.title}</strong> {new Date(s.starts_at).toLocaleString()} to {new Date(s.ends_at).toLocaleString()} <strong>{s.name}</strong> {s.date} {s.start_time.slice(0, 5)}{s.end_time.slice(0, 5)}
</li> </li>
))} ))}
</ul> </ul>
@@ -92,9 +93,9 @@ export default function Dashboard() {
) : ( ) : (
<ul> <ul>
{notifications.map(n => ( {notifications.map(n => (
<li key={n.id} className={n.read ? 'read' : 'unread'}> <li key={n.id} className={n.is_read ? 'read' : 'unread'}>
{n.message} {n.message}
{!n.read && ( {!n.is_read && (
<button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button> <button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button>
)} )}
</li> </li>

View File

@@ -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(
<MemoryRouter initialEntries={[path]}>
<Schedules />
</MemoryRouter>
);
}
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);
});
});

View File

@@ -1,106 +1,636 @@
import React, { useEffect, useState, FormEvent } from 'react'; import React, { useEffect, useState, FormEvent, useCallback } from 'react';
import { api, Schedule } from '../api'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import {
api,
ShiftTemplate,
ShiftInstance,
Volunteer,
TimeOffRequest,
CreateShiftTemplateInput,
TemplateRole,
OPERATIONAL_ROLES,
} from '../api';
import { useAuth } from '../auth'; 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<CreateShiftTemplateInput>;
onSave: (data: CreateShiftTemplateInput) => Promise<void>;
onCancel: () => void;
title: string;
}
function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
const [form, setForm] = useState<CreateShiftTemplateInput>({ ...blankTemplate(), ...initial });
const [roleRow, setRoleRow] = useState<TemplateRole>({ 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 (
<form className="card" onSubmit={handleSubmit}>
<h3>{title}</h3>
{error && <p className="error">{error}</p>}
<label>
Name
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
</label>
<label>
Day of Week
<select value={form.day_of_week} onChange={e => setForm(f => ({ ...f, day_of_week: Number(e.target.value) }))}>
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
</select>
</label>
<label>
Start Time
<input type="time" value={form.start_time.slice(0, 5)}
onChange={e => setForm(f => ({ ...f, start_time: e.target.value + ':00' }))} required />
</label>
<label>
End Time
<input type="time" value={form.end_time.slice(0, 5)}
onChange={e => setForm(f => ({ ...f, end_time: e.target.value + ':00' }))} required />
</label>
<label>
Min Capacity
<input type="number" min={1} value={form.min_capacity}
onChange={e => setForm(f => ({ ...f, min_capacity: Number(e.target.value) }))} required />
</label>
<label>
Max Capacity
<input type="number" min={1} value={form.max_capacity}
onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) }))} required />
</label>
<fieldset>
<legend>Role Requirements</legend>
{(form.roles ?? []).map((r, i) => (
<div key={i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.25rem' }}>
<span>{r.count}× {r.role_name}</span>
<button type="button" className="btn-danger btn-small" onClick={() => removeRole(i)}>Remove</button>
</div>
))}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<label style={{ flex: 1 }}>
Role
<select value={roleRow.role_name} onChange={e => setRoleRow(r => ({ ...r, role_name: e.target.value }))}>
<option value="">Select role</option>
{OPERATIONAL_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<label>
Count
<input type="number" min={1} value={roleRow.count} style={{ width: '4rem' }}
onChange={e => setRoleRow(r => ({ ...r, count: Number(e.target.value) }))} />
</label>
<button type="button" onClick={addRole}>Add Role</button>
</div>
</fieldset>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
</form>
);
}
// ---------------------------------------------------------------------------
// 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<Volunteer[]>([]);
const [templateRoles, setTemplateRoles] = useState<TemplateRole[]>([]);
const [unavailableIds, setUnavailableIds] = useState<Set<number>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<number>>(
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<void>[] = [
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<number>();
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<string, Volunteer[]>();
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 <div className="card"><p>Loading volunteers</p></div>;
return (
<form className="card" onSubmit={handleSubmit}>
<h3>Edit Shift: {instance.name} {instance.date}</h3>
{error && <p className="error">{error}</p>}
{templateRoles.length > 0 && (
<p style={{ marginBottom: '0.5rem', color: '#666' }}>
<strong>Required:</strong>{' '}
{templateRoles.map(r => `${r.count}× ${r.role_name}`).join(', ')}
</p>
)}
<fieldset>
<legend>Volunteers ({selectedIds.size} selected)</legend>
{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 (
<div key={roleName} style={{ marginBottom: '0.75rem' }}>
<strong>
{roleName}
{required && (
<span style={{ fontWeight: 'normal', color: selectedInRole >= required.count ? '#2a7' : '#c55' }}>
{' '}({selectedInRole}/{required.count})
</span>
)}
</strong>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: '0.25rem', marginTop: '0.25rem' }}>
{vols.map(v => (
<label key={v.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={selectedIds.has(v.id)}
onChange={() => toggleVolunteer(v.id)}
/>
<span>{v.name}{v.is_trainee ? ' (trainee)' : ''}</span>
</label>
))}
</div>
</div>
);
})}
</fieldset>
{onTimeOff.length > 0 && (
<p style={{ marginTop: '0.5rem', color: '#999', fontSize: '0.9em' }}>
<strong>On approved time off:</strong>{' '}
{onTimeOff.map(v => v.name).join(', ')}
</p>
)}
<div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem' }}>
<label>
Min Capacity
<input type="number" min={1} value={minCap} style={{ width: '5rem' }}
onChange={e => setMinCap(Number(e.target.value))} />
</label>
<label>
Max Capacity
<input type="number" min={1} value={maxCap} style={{ width: '5rem' }}
onChange={e => setMaxCap(Number(e.target.value))} />
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
</form>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function Schedules() { export default function Schedules() {
const { role } = useAuth(); const { role } = useAuth();
const [schedules, setSchedules] = useState<Schedule[]>([]); 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<ShiftInstance[]>([]);
const [templates, setTemplates] = useState<ShiftTemplate[]>([]);
const [error, setError] = 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<ShiftTemplate | null>(null);
const [editingInstance, setEditingInstance] = useState<ShiftInstance | null>(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(() => { 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) { function prevMonth() {
e.preventDefault(); 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(''); setError('');
try { try {
const sc = await api.createSchedule(form); const newInstances = await api.generateShifts(year, month);
setSchedules(prev => [...prev, sc]); setInstances(newInstances);
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
setShowForm(false);
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
} }
} }
async function handleDelete(id: number) { async function handlePublish() {
if (!window.confirm('Delete this schedule?')) return; setError('');
try { try {
await api.deleteSchedule(id); await api.publishShifts(year, month);
setSchedules(prev => prev.filter(s => s.id !== id)); const updated = await api.listShifts(year, month);
setInstances(updated);
} catch (err: any) { } catch (err: any) {
setError(err.message); 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 ( return (
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<h2>Schedules</h2> <h2>Schedules</h2>
{role === 'admin' && ( {role === 'admin' && (
<button onClick={() => setShowForm(v => !v)}> <div style={{ display: 'flex', gap: '0.5rem' }}>
{showForm ? 'Cancel' : 'Add Shift'} <button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
{isTemplatesView ? 'View Shifts' : 'Manage Templates'}
</button> </button>
</div>
)} )}
</div> </div>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
{showForm && ( {/* ---- Templates view ---- */}
<form className="card" onSubmit={handleCreate}> {isTemplatesView && role === 'admin' && (
<h3>New Shift</h3> <>
<label> <div className="page-header" style={{ marginBottom: '1rem' }}>
Title <h3>Shift Templates</h3>
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required /> <button onClick={() => { setShowTemplateForm(true); setEditingTemplate(null); }}>
</label> + New Template
<label> </button>
Starts At </div>
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
</label> {showTemplateForm && !editingTemplate && (
<label> <TemplateForm
Ends At title="New Template"
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required /> onSave={handleCreateTemplate}
</label> onCancel={() => setShowTemplateForm(false)}
<label> />
Notes
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
</label>
<button type="submit">Create</button>
</form>
)} )}
{schedules.length === 0 ? ( {editingTemplate && (
<p>No schedules found.</p> <TemplateForm
title={`Edit: ${editingTemplate.name}`}
initial={editingTemplate}
onSave={handleUpdateTemplate}
onCancel={() => setEditingTemplate(null)}
/>
)}
{templates.length === 0 ? (
<p>No templates yet.</p>
) : ( ) : (
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Name</th>
<th>Starts</th> <th>Day</th>
<th>Ends</th> <th>Time</th>
<th>Notes</th> <th>Capacity</th>
{role === 'admin' && <th>Actions</th>} <th>Roles</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{schedules.map(s => ( {templates.map(t => (
<tr key={s.id}> <tr key={t.id}>
<td>{s.title}</td> <td>{t.name}</td>
<td>{new Date(s.starts_at).toLocaleString()}</td> <td>{DAY_NAMES[t.day_of_week]}</td>
<td>{new Date(s.ends_at).toLocaleString()}</td> <td>{t.start_time.slice(0, 5)}{t.end_time.slice(0, 5)}</td>
<td>{s.notes ?? '—'}</td> <td>{t.min_capacity}{t.max_capacity}</td>
{role === 'admin' && ( <td>{(t.roles ?? []).map(r => `${r.count}× ${r.role_name}`).join(', ') || '—'}</td>
<td> <td>
<button className="btn-danger btn-small" onClick={() => handleDelete(s.id)}>Delete</button> <button className="btn-small" onClick={() => { setEditingTemplate(t); setShowTemplateForm(false); }}>Edit</button>
{' '}
<button className="btn-danger btn-small" onClick={() => handleDeleteTemplate(t.id)}>Delete</button>
</td> </td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)} )}
</>
)}
{/* ---- Shifts view ---- */}
{!isTemplatesView && (
<>
{/* Month navigation */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<button onClick={prevMonth}></button>
<strong>{MONTH_NAMES[month - 1]} {year}</strong>
<button onClick={nextMonth}></button>
{role === 'admin' && (
<div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto' }}>
<button onClick={handleGenerate}>Generate</button>
{hasDraft && <button onClick={handlePublish}>Publish</button>}
{allPublished && (
<button className="btn-danger" onClick={handleUnpublish}>Unpublish</button>
)}
</div>
)}
</div>
{/* Instance edit form */}
{editingInstance && (
<ShiftEditForm
instance={editingInstance}
templateId={editingInstance.template_id}
onSave={handleInstanceSaved}
onCancel={() => setEditingInstance(null)}
/>
)}
{instances.length === 0 ? (
<p>No shifts for {MONTH_NAMES[month - 1]} {year}.{role === 'admin' && ' Use Generate to create shifts from templates.'}</p>
) : (
<table>
<thead>
<tr>
<th>Date</th>
<th>Shift</th>
<th>Time</th>
<th>Status</th>
<th>Volunteers</th>
<th>Capacity</th>
{role === 'admin' && <th>Actions</th>}
</tr>
</thead>
<tbody>
{instances.map(inst => {
const confirmed = inst.volunteers.some(v => v.confirmed);
return (
<tr key={inst.id}>
<td>{inst.date}</td>
<td>{inst.name}</td>
<td>{inst.start_time.slice(0, 5)}{inst.end_time.slice(0, 5)}</td>
<td>
<span className={inst.status === 'published' ? 'badge-success' : 'badge-neutral'}>
{inst.status}
</span>
</td>
<td>
{inst.volunteers.length === 0 ? '—' : inst.volunteers.map(v => (
<span key={v.volunteer_id} title={v.confirmed ? 'Confirmed' : 'Unconfirmed'}>
{v.name}{v.confirmed ? ' ✓' : ' ⚠'}
{' '}
</span>
))}
</td>
<td>{inst.volunteers.length}/{inst.max_capacity}</td>
{role === 'admin' && (
<td>
<button className="btn-small" onClick={() => setEditingInstance(inst)}>Edit</button>
</td>
)}
{role !== 'admin' && inst.status === 'published' && !confirmed && (
<td>
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Setup from './Setup';
import { api } from '../api';
import { AuthProvider } from '../auth';
jest.mock('../api', () => ({
api: {
getSetupStatus: jest.fn(),
createSetupAdmin: jest.fn(),
},
}));
// Mock useSetup from App to provide setNeedsSetup
const mockSetNeedsSetup = jest.fn();
jest.mock('../App', () => ({
useSetup: () => ({ setNeedsSetup: mockSetNeedsSetup }),
}));
const mockCreateSetupAdmin = api.createSetupAdmin as jest.Mock;
function renderSetup() {
return render(
<AuthProvider>
<MemoryRouter initialEntries={['/setup']}>
<Routes>
<Route path="/setup" element={<Setup />} />
<Route path="/" element={<div>Dashboard</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>,
);
}
beforeEach(() => {
mockCreateSetupAdmin.mockReset();
mockSetNeedsSetup.mockReset();
});
test('renders all form fields', () => {
renderSetup();
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create admin account/i })).toBeInTheDocument();
});
test('shows error when passwords do not match', async () => {
renderSetup();
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password1' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password2' } });
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
expect(await screen.findByText(/passwords do not match/i)).toBeInTheDocument();
});
test('shows error when password too short', async () => {
renderSetup();
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'short' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'short' } });
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument();
});
test('calls api.createSetupAdmin and navigates on success', async () => {
mockCreateSetupAdmin.mockResolvedValueOnce({ token: 'jwt-token' });
renderSetup();
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
await waitFor(() => {
expect(mockCreateSetupAdmin).toHaveBeenCalledWith({
name: 'Admin',
email: 'admin@example.com',
password: 'goodpassword',
});
});
await waitFor(() => {
expect(mockSetNeedsSetup).toHaveBeenCalledWith(false);
});
expect(await screen.findByText(/dashboard/i)).toBeInTheDocument();
});
test('shows error when API returns failure', async () => {
mockCreateSetupAdmin.mockRejectedValueOnce(new Error('setup already completed'));
renderSetup();
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: 'Admin' } });
fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'admin@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'goodpassword' } });
fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'goodpassword' } });
fireEvent.click(screen.getByRole('button', { name: /create admin account/i }));
expect(await screen.findByText(/setup already completed/i)).toBeInTheDocument();
});

77
web/src/pages/Setup.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../auth';
import { useSetup } from '../App';
export default function Setup() {
const { login } = useAuth();
const { setNeedsSetup } = useSetup();
const navigate = useNavigate();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
if (!name || !email || !password) {
setError('All fields are required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (password !== confirm) {
setError('Passwords do not match');
return;
}
setSubmitting(true);
try {
const { token } = await api.createSetupAdmin({ name, email, password });
login(token);
setNeedsSetup(false);
navigate('/');
} catch (err: any) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="auth-page">
<h1>Walkies</h1>
<h2>Initial Setup</h2>
<p>Create the first admin account to get started.</p>
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<label>
Name
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
</label>
<label>
Email
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</label>
<label>
Confirm Password
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required />
</label>
<button type="submit" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Admin Account'}
</button>
</form>
</div>
);
}