Implement Issue #2: Scheduling & Publishing #10
195
.claude/skills/actions-runs/SKILL.md
Normal file
195
.claude/skills/actions-runs/SKILL.md
Normal 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.
|
||||
@@ -2,7 +2,7 @@
|
||||
FROM node:22-alpine AS frontend
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
|
||||
12
Taskfile.yml
12
Taskfile.yml
@@ -104,16 +104,16 @@ tasks:
|
||||
cmd: docker build -t walkies .
|
||||
|
||||
docker:up:
|
||||
desc: Build and start with docker-compose
|
||||
cmd: docker-compose up --build
|
||||
desc: Build and start with docker compose
|
||||
cmd: docker compose up --build
|
||||
|
||||
docker:down:
|
||||
desc: Stop docker-compose services
|
||||
cmd: docker-compose down
|
||||
desc: Stop docker compose services
|
||||
cmd: docker compose down
|
||||
|
||||
docker:logs:
|
||||
desc: Tail docker-compose logs
|
||||
cmd: docker-compose logs -f
|
||||
desc: Tail docker compose logs
|
||||
cmd: docker compose logs -f
|
||||
|
||||
# ── Utilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ package db
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func Open(dsn string) (*sql.DB, error) {
|
||||
@@ -22,6 +23,10 @@ func Open(dsn string) (*sql.DB, error) {
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
for _, stmt := range statements {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1060 {
|
||||
continue // duplicate column — already exists
|
||||
}
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ var statements = []string{
|
||||
active TINYINT NOT NULL DEFAULT 1,
|
||||
is_trainee TINYINT NOT NULL DEFAULT 0,
|
||||
phone VARCHAR(20) NULL,
|
||||
operational_roles TEXT NOT NULL DEFAULT '',
|
||||
operational_roles TEXT NOT NULL,
|
||||
notification_preference VARCHAR(50) NOT NULL DEFAULT 'email',
|
||||
admin_notes TEXT NULL,
|
||||
last_login DATETIME NULL,
|
||||
@@ -19,15 +19,15 @@ var statements = []string{
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
// Additive column migrations for existing deployments
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS is_trainee TINYINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS phone VARCHAR(20) NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS operational_roles TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS admin_notes TEXT NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS last_login DATETIME NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN IF NOT EXISTS invite_expires_at DATETIME NULL`,
|
||||
// Additive column migrations for existing deployments (duplicates ignored at runtime)
|
||||
`ALTER TABLE volunteers ADD COLUMN is_trainee TINYINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE volunteers ADD COLUMN phone VARCHAR(20) NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN operational_roles TEXT NOT NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN notification_preference VARCHAR(50) NOT NULL DEFAULT 'email'`,
|
||||
`ALTER TABLE volunteers ADD COLUMN admin_notes TEXT NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN last_login DATETIME NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN invite_token VARCHAR(255) NULL`,
|
||||
`ALTER TABLE volunteers ADD COLUMN invite_expires_at DATETIME NULL`,
|
||||
`CREATE TABLE IF NOT EXISTS schedules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
volunteer_id INT NOT NULL,
|
||||
@@ -72,10 +72,67 @@ var statements = []string{
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
volunteer_id INT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read TINYINT NOT NULL DEFAULT 0,
|
||||
is_read TINYINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||
INDEX idx_volunteer_id (volunteer_id),
|
||||
INDEX idx_read (read)
|
||||
INDEX idx_is_read (is_read)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shift_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
day_of_week TINYINT NOT NULL COMMENT '0=Sunday 1=Monday ... 6=Saturday (matches Go time.Weekday)',
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
min_capacity INT NOT NULL DEFAULT 1,
|
||||
max_capacity INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shift_template_roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
role_name VARCHAR(255) NOT NULL,
|
||||
count INT NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE,
|
||||
INDEX idx_template_id (template_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shift_template_volunteers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
volunteer_id INT NOT NULL,
|
||||
UNIQUE KEY uq_template_volunteer (template_id, volunteer_id),
|
||||
FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shift_instances (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
start_time TIME NOT NULL,
|
||||
end_time TIME NOT NULL,
|
||||
min_capacity INT NOT NULL DEFAULT 1,
|
||||
max_capacity INT NOT NULL DEFAULT 1,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft or published',
|
||||
year INT NOT NULL,
|
||||
month INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE SET NULL,
|
||||
INDEX idx_year_month (year, month),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS shift_instance_volunteers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
instance_id INT NOT NULL,
|
||||
volunteer_id INT NOT NULL,
|
||||
confirmed TINYINT NOT NULL DEFAULT 0,
|
||||
confirmed_at DATETIME NULL,
|
||||
UNIQUE KEY uq_instance_volunteer (instance_id, volunteer_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES shift_instances(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||
INDEX idx_instance_id (instance_id),
|
||||
INDEX idx_volunteer_id (volunteer_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type Notification struct {
|
||||
ID int64 `json:"id"`
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
Message string `json:"message"`
|
||||
Read bool `json:"read"`
|
||||
Read bool `json:"is_read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
|
||||
n := &Notification{}
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE id = ?`, id,
|
||||
`SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE id = ?`, id,
|
||||
).Scan(&n.ID, &n.VolunteerID, &n.Message, &n.Read, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
@@ -56,7 +56,7 @@ func (s *Store) GetByID(ctx context.Context, id int64) (*Notification, error) {
|
||||
|
||||
func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Notification, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, volunteer_id, message, read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
|
||||
`SELECT id, volunteer_id, message, is_read, created_at FROM notifications WHERE volunteer_id = ? ORDER BY created_at DESC`,
|
||||
volunteerID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -77,9 +77,15 @@ func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Noti
|
||||
return notifications, rows.Err()
|
||||
}
|
||||
|
||||
// CreateNotification satisfies the schedule.Notifier interface.
|
||||
func (s *Store) CreateNotification(ctx context.Context, volunteerID int64, message string) error {
|
||||
_, err := s.Create(ctx, volunteerID, message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
|
||||
`UPDATE notifications SET is_read = 1 WHERE id = ? AND volunteer_id = ?`,
|
||||
id, volunteerID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,99 +1,329 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/respond"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Notifier is the subset of notification.Store the handler needs.
|
||||
type Notifier interface {
|
||||
CreateNotification(ctx context.Context, volunteerID int64, message string) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
store Storer
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func NewHandler(store *Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
// Storer is the interface the Handler depends on.
|
||||
type Storer interface {
|
||||
CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error)
|
||||
GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error)
|
||||
ListTemplates(ctx context.Context) ([]ShiftTemplate, error)
|
||||
UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error)
|
||||
DeleteTemplate(ctx context.Context, id int64) error
|
||||
|
||||
GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error)
|
||||
ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error)
|
||||
GetInstance(ctx context.Context, id int64) (*ShiftInstance, error)
|
||||
UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (*ShiftInstance, []int64, error)
|
||||
PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error)
|
||||
UnpublishMonth(ctx context.Context, year, month int) ([]int64, error)
|
||||
ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error
|
||||
}
|
||||
|
||||
// GET /api/v1/schedules
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
volunteerID := int64(0)
|
||||
if claims.Role != "admin" {
|
||||
volunteerID = claims.VolunteerID
|
||||
func NewHandler(store *Store, notifier Notifier) *Handler {
|
||||
return &Handler{store: store, notifier: notifier}
|
||||
}
|
||||
schedules, err := h.store.List(r.Context(), volunteerID)
|
||||
|
||||
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
|
||||
return &Handler{store: store, notifier: notifier}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/v1/shift-templates
|
||||
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := h.store.ListTemplates(r.Context())
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list templates")
|
||||
return
|
||||
}
|
||||
if schedules == nil {
|
||||
schedules = []Schedule{}
|
||||
if templates == nil {
|
||||
templates = []ShiftTemplate{}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, schedules)
|
||||
respond.JSON(w, http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// POST /api/v1/schedules
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var in CreateInput
|
||||
// POST /api/v1/shift-templates
|
||||
func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var in CreateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
in.VolunteerID = claims.VolunteerID
|
||||
}
|
||||
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
|
||||
if in.Name == "" || in.StartTime == "" || in.EndTime == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
|
||||
return
|
||||
}
|
||||
sc, err := h.store.Create(r.Context(), in)
|
||||
if in.MinCapacity <= 0 {
|
||||
in.MinCapacity = 1
|
||||
}
|
||||
if in.MaxCapacity < in.MinCapacity {
|
||||
in.MaxCapacity = in.MinCapacity
|
||||
}
|
||||
t, err := h.store.CreateTemplate(r.Context(), in)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create template")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusCreated, sc)
|
||||
respond.JSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// PUT /api/v1/schedules/{id}
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
// PUT /api/v1/shift-templates/{id}
|
||||
func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var in UpdateInput
|
||||
var in UpdateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
sc, err := h.store.Update(r.Context(), id, in)
|
||||
t, err := h.store.UpdateTemplate(r.Context(), id, in)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "schedule not found")
|
||||
respond.Error(w, http.StatusNotFound, "template not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update template")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, sc)
|
||||
respond.JSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/schedules/{id}
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// DELETE /api/v1/shift-templates/{id}
|
||||
func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
|
||||
if err := h.store.DeleteTemplate(r.Context(), id); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not delete template")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/v1/shifts?year=2026&month=4
|
||||
func (h *Handler) ListInstances(w http.ResponseWriter, r *http.Request) {
|
||||
year, month := parseYearMonth(r)
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
volunteerID := int64(0)
|
||||
if claims.Role != "admin" {
|
||||
volunteerID = claims.VolunteerID
|
||||
}
|
||||
|
||||
instances, err := h.store.ListInstances(r.Context(), year, month, volunteerID)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list shifts")
|
||||
return
|
||||
}
|
||||
if instances == nil {
|
||||
instances = []ShiftInstance{}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, instances)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/generate body: {"year":2026,"month":4}
|
||||
func (h *Handler) GenerateInstances(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
instances, err := h.store.GenerateInstances(r.Context(), body.Year, body.Month)
|
||||
if errors.Is(err, ErrAlreadyExists) {
|
||||
respond.Error(w, http.StatusConflict, "shifts already generated for this period")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not generate shifts")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusCreated, instances)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/publish body: {"year":2026,"month":4}
|
||||
func (h *Handler) PublishMonth(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
|
||||
byVol, err := h.store.PublishMonth(r.Context(), body.Year, body.Month)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not publish schedule")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify each affected volunteer (FR-S04)
|
||||
mn := time.Month(body.Month).String()
|
||||
for vid, shifts := range byVol {
|
||||
msg := fmt.Sprintf("Your schedule for %s %d has been published. You have %d shift(s).",
|
||||
mn, body.Year, len(shifts))
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, map[string]any{
|
||||
"year": body.Year,
|
||||
"month": body.Month,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/unpublish body: {"year":2026,"month":4}
|
||||
func (h *Handler) UnpublishMonth(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
|
||||
volunteerIDs, err := h.store.UnpublishMonth(r.Context(), body.Year, body.Month)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not unpublish schedule")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify affected volunteers (FR-S05)
|
||||
mn := time.Month(body.Month).String()
|
||||
for _, vid := range volunteerIDs {
|
||||
msg := fmt.Sprintf("The schedule for %s %d has been retracted.", mn, body.Year)
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, map[string]any{
|
||||
"year": body.Year,
|
||||
"month": body.Month,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v1/shifts/{id}
|
||||
func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var in UpdateInstanceInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
inst, added, err := h.store.UpdateInstance(r.Context(), id, in)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "shift not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update shift")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify all volunteers on a published shift of the change (FR-S09)
|
||||
if inst.Status == "published" && in.VolunteerIDs != nil {
|
||||
for _, v := range inst.Volunteers {
|
||||
msg := fmt.Sprintf("Your shift on %s (%s–%s) has been updated. Please re-confirm.", inst.Date, inst.StartTime, inst.EndTime)
|
||||
h.notifier.CreateNotification(r.Context(), v.VolunteerID, msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Notify only newly added volunteers (FR-S10)
|
||||
if inst.Status == "published" && len(added) > 0 {
|
||||
for _, vid := range added {
|
||||
msg := fmt.Sprintf("You have been added to a shift on %s (%s–%s).", inst.Date, inst.StartTime, inst.EndTime)
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, inst)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/{id}/confirm
|
||||
func (h *Handler) ConfirmShift(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if err := h.store.ConfirmShift(r.Context(), id, claims.VolunteerID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "shift assignment not found")
|
||||
return
|
||||
}
|
||||
respond.Error(w, http.StatusInternalServerError, "could not confirm shift")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func parseYearMonth(r *http.Request) (year, month int) {
|
||||
now := time.Now()
|
||||
year = now.Year()
|
||||
month = int(now.Month())
|
||||
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil {
|
||||
year = y
|
||||
}
|
||||
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
|
||||
month = m
|
||||
}
|
||||
return year, month
|
||||
}
|
||||
|
||||
476
internal/schedule/handler_test.go
Normal file
476
internal/schedule/handler_test.go
Normal 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)
|
||||
@@ -8,35 +8,96 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = fmt.Errorf("schedule not found")
|
||||
var (
|
||||
ErrNotFound = fmt.Errorf("not found")
|
||||
ErrAlreadyExists = fmt.Errorf("instances already generated for this period")
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ShiftTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
Title string `json:"title"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt time.Time `json:"ends_at"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat
|
||||
StartTime string `json:"start_time"` // "HH:MM:SS"
|
||||
EndTime string `json:"end_time"`
|
||||
MinCapacity int `json:"min_capacity"`
|
||||
MaxCapacity int `json:"max_capacity"`
|
||||
Roles []TemplateRole `json:"roles"`
|
||||
VolunteerIDs []int64 `json:"volunteer_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
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"`
|
||||
Title string `json:"title"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Notes string `json:"notes"`
|
||||
Name string `json:"name"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Title *string `json:"title"`
|
||||
StartsAt *string `json:"starts_at"`
|
||||
EndsAt *string `json:"ends_at"`
|
||||
Notes *string `json:"notes"`
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CreateTemplateInput struct {
|
||||
Name string `json:"name"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
MinCapacity int `json:"min_capacity"`
|
||||
MaxCapacity int `json:"max_capacity"`
|
||||
Roles []TemplateRole `json:"roles"`
|
||||
VolunteerIDs []int64 `json:"volunteer_ids"`
|
||||
}
|
||||
|
||||
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 {
|
||||
db *sql.DB
|
||||
@@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
|
||||
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Store) CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_templates (name, day_of_week, start_time, end_time, min_capacity, max_capacity)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
in.Name, in.DayOfWeek, in.StartTime, in.EndTime, in.MinCapacity, in.MaxCapacity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert schedule: %w", err)
|
||||
return nil, fmt.Errorf("insert template: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetByID(ctx, id)
|
||||
|
||||
if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetTemplate(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
sc := &Schedule{}
|
||||
var startsAt, endsAt, createdAt, updatedAt string
|
||||
var notes sql.NullString
|
||||
func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
|
||||
t := &ShiftTemplate{}
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id,
|
||||
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt)
|
||||
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||
FROM shift_templates WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
|
||||
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get schedule: %w", err)
|
||||
}
|
||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if notes.Valid {
|
||||
sc.Notes = notes.String
|
||||
}
|
||||
return sc, nil
|
||||
return nil, fmt.Errorf("get template: %w", err)
|
||||
}
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
|
||||
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) {
|
||||
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
|
||||
args := []any{}
|
||||
if volunteerID > 0 {
|
||||
query += ` WHERE volunteer_id = ?`
|
||||
args = append(args, volunteerID)
|
||||
}
|
||||
query += ` ORDER BY starts_at`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
roles, err := s.templateRoles(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list schedules: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
t.Roles = roles
|
||||
|
||||
vids, err := s.templateVolunteerIDs(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.VolunteerIDs = vids
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||
FROM shift_templates ORDER BY day_of_week, start_time`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list templates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var schedules []Schedule
|
||||
var templates []ShiftTemplate
|
||||
for rows.Next() {
|
||||
var sc Schedule
|
||||
var startsAt, endsAt, createdAt, updatedAt string
|
||||
var notes sql.NullString
|
||||
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil {
|
||||
var t ShiftTemplate
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
|
||||
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if notes.Valid {
|
||||
sc.Notes = notes.String
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
templates = append(templates, t)
|
||||
}
|
||||
schedules = append(schedules, sc)
|
||||
}
|
||||
return schedules, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) {
|
||||
sc, err := s.GetByID(ctx, id)
|
||||
// Load roles and volunteers for each template
|
||||
for i := range templates {
|
||||
roles, err := s.templateRoles(ctx, templates[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := sc.Title
|
||||
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
|
||||
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
|
||||
notes := sc.Notes
|
||||
templates[i].Roles = roles
|
||||
|
||||
if in.Title != nil {
|
||||
title = *in.Title
|
||||
vids, err := s.templateVolunteerIDs(ctx, templates[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.StartsAt != nil {
|
||||
startsAt = *in.StartsAt
|
||||
templates[i].VolunteerIDs = vids
|
||||
}
|
||||
if in.EndsAt != nil {
|
||||
endsAt = *in.EndsAt
|
||||
return templates, nil
|
||||
}
|
||||
if in.Notes != nil {
|
||||
notes = *in.Notes
|
||||
|
||||
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
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`,
|
||||
title, startsAt, endsAt, notes, id,
|
||||
|
||||
name := t.Name
|
||||
dow := t.DayOfWeek
|
||||
startTime := t.StartTime
|
||||
endTime := t.EndTime
|
||||
minCap := t.MinCapacity
|
||||
maxCap := t.MaxCapacity
|
||||
|
||||
if in.Name != nil {
|
||||
name = *in.Name
|
||||
}
|
||||
if in.DayOfWeek != nil {
|
||||
dow = *in.DayOfWeek
|
||||
}
|
||||
if in.StartTime != nil {
|
||||
startTime = *in.StartTime
|
||||
}
|
||||
if in.EndTime != nil {
|
||||
endTime = *in.EndTime
|
||||
}
|
||||
if in.MinCapacity != nil {
|
||||
minCap = *in.MinCapacity
|
||||
}
|
||||
if in.MaxCapacity != nil {
|
||||
maxCap = *in.MaxCapacity
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE shift_templates SET name=?, day_of_week=?, start_time=?, end_time=?,
|
||||
min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
|
||||
name, dow, startTime, endTime, minCap, maxCap, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update schedule: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
return nil, fmt.Errorf("update template: %w", err)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, 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) DeleteTemplate(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateInstances creates draft shift instances for every template × date in
|
||||
// the given month. Returns ErrAlreadyExists if instances already exist for
|
||||
// that month (FR-S02).
|
||||
func (s *Store) GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM shift_instances WHERE year = ? AND month = ?`, year, month,
|
||||
).Scan(&count); err != nil {
|
||||
return nil, fmt.Errorf("check existing: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, ErrAlreadyExists
|
||||
}
|
||||
|
||||
templates, err := s.ListTemplates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find all dates in the month for each template's day of week
|
||||
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||
daysInMonth := daysIn(year, month)
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var instanceIDs []int64
|
||||
for _, tmpl := range templates {
|
||||
for d := 0; d < daysInMonth; d++ {
|
||||
day := first.AddDate(0, 0, d)
|
||||
if int(day.Weekday()) != tmpl.DayOfWeek {
|
||||
continue
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_instances
|
||||
(template_id, name, date, start_time, end_time, min_capacity, max_capacity, status, year, month)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`,
|
||||
tmpl.ID, tmpl.Name, day.Format("2006-01-02"),
|
||||
tmpl.StartTime, tmpl.EndTime,
|
||||
tmpl.MinCapacity, tmpl.MaxCapacity,
|
||||
year, month,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert instance: %w", err)
|
||||
}
|
||||
instID, _ := res.LastInsertId()
|
||||
instanceIDs = append(instanceIDs, instID)
|
||||
|
||||
// Copy recurring volunteer assignments from template
|
||||
for _, vid := range tmpl.VolunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
|
||||
instID, vid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("copy volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return s.ListInstances(ctx, year, month, 0)
|
||||
}
|
||||
|
||||
// ListInstances returns instances for a month. When volunteerID > 0, only
|
||||
// returns published instances where that volunteer is assigned.
|
||||
func (s *Store) ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) {
|
||||
query := `SELECT id, template_id, name, date, start_time, end_time,
|
||||
min_capacity, max_capacity, status, year, month, created_at, updated_at
|
||||
FROM shift_instances WHERE year = ? AND month = ?`
|
||||
args := []any{year, month}
|
||||
|
||||
if volunteerID > 0 {
|
||||
query += ` AND status = 'published'
|
||||
AND id IN (SELECT instance_id FROM shift_instance_volunteers WHERE volunteer_id = ?)`
|
||||
args = append(args, volunteerID)
|
||||
}
|
||||
query += ` ORDER BY date, start_time`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []ShiftInstance
|
||||
for rows.Next() {
|
||||
inst, err := scanInstance(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances = append(instances, *inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range instances {
|
||||
vols, err := s.instanceVolunteers(ctx, instances[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances[i].Volunteers = vols
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, template_id, name, date, start_time, end_time,
|
||||
min_capacity, max_capacity, status, year, month, created_at, updated_at
|
||||
FROM shift_instances WHERE id = ?`, id)
|
||||
inst, err := scanInstanceRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance: %w", err)
|
||||
}
|
||||
vols, err := s.instanceVolunteers(ctx, inst.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst.Volunteers = vols
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// UpdateInstance edits volunteer assignments and/or capacity on any instance.
|
||||
// For published instances, volunteer confirmation statuses are reset (FR-S09).
|
||||
// Returns the previous and new volunteer ID sets so the caller can send notifications.
|
||||
func (s *Store) UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (inst *ShiftInstance, added []int64, err error) {
|
||||
inst, err = s.GetInstance(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
tx, txErr := s.db.BeginTx(ctx, nil)
|
||||
if txErr != nil {
|
||||
return nil, nil, fmt.Errorf("begin tx: %w", txErr)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if in.MinCapacity != nil || in.MaxCapacity != nil {
|
||||
minCap := inst.MinCapacity
|
||||
maxCap := inst.MaxCapacity
|
||||
if in.MinCapacity != nil {
|
||||
minCap = *in.MinCapacity
|
||||
}
|
||||
if in.MaxCapacity != nil {
|
||||
maxCap = *in.MaxCapacity
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
|
||||
minCap, maxCap, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("update capacity: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if in.VolunteerIDs != nil {
|
||||
// Determine newly added volunteers (FR-S10)
|
||||
existing := make(map[int64]bool)
|
||||
for _, v := range inst.Volunteers {
|
||||
existing[v.VolunteerID] = true
|
||||
}
|
||||
for _, vid := range *in.VolunteerIDs {
|
||||
if !existing[vid] {
|
||||
added = append(added, vid)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace assignments
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM shift_instance_volunteers WHERE instance_id = ?`, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("clear volunteers: %w", err)
|
||||
}
|
||||
for _, vid := range *in.VolunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
|
||||
id, vid,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("insert volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset confirmation for published shifts (FR-S09)
|
||||
if inst.Status == "published" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE shift_instance_volunteers SET confirmed=0, confirmed_at=NULL WHERE instance_id=?`, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("reset confirmations: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
inst, err = s.GetInstance(ctx, id)
|
||||
return inst, added, err
|
||||
}
|
||||
|
||||
// PublishMonth marks all draft instances for the month as published and returns
|
||||
// a map of volunteerID → []ShiftInstance for notification purposes (FR-S04).
|
||||
func (s *Store) PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET status='published', updated_at=NOW()
|
||||
WHERE year=? AND month=? AND status='draft'`, year, month,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
|
||||
instances, err := s.ListInstances(ctx, year, month, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byVol := make(map[int64][]ShiftInstance)
|
||||
for _, inst := range instances {
|
||||
for _, v := range inst.Volunteers {
|
||||
byVol[v.VolunteerID] = append(byVol[v.VolunteerID], inst)
|
||||
}
|
||||
}
|
||||
return byVol, nil
|
||||
}
|
||||
|
||||
// UnpublishMonth marks all published instances for the month back to draft and
|
||||
// returns volunteer IDs who had assignments (for notifications FR-S05).
|
||||
func (s *Store) UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) {
|
||||
// Collect affected volunteer IDs before unpublishing
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT siv.volunteer_id
|
||||
FROM shift_instance_volunteers siv
|
||||
JOIN shift_instances si ON siv.instance_id = si.id
|
||||
WHERE si.year=? AND si.month=? AND si.status='published'`, year, month,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var volunteerIDs []int64
|
||||
for rows.Next() {
|
||||
var vid int64
|
||||
if err := rows.Scan(&vid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
volunteerIDs = append(volunteerIDs, vid)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET status='draft', updated_at=NOW()
|
||||
WHERE year=? AND month=? AND status='published'`, year, month,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("unpublish: %w", err)
|
||||
}
|
||||
|
||||
return volunteerIDs, nil
|
||||
}
|
||||
|
||||
// ConfirmShift marks a volunteer's attendance confirmation for a shift (FR-S06).
|
||||
func (s *Store) ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instance_volunteers SET confirmed=1, confirmed_at=NOW()
|
||||
WHERE instance_id=? AND volunteer_id=?`, instanceID, volunteerID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm shift: %w", err)
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]TemplateRole, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, template_id, role_name, count FROM shift_template_roles WHERE template_id = ?`, templateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
roles := make([]TemplateRole, 0)
|
||||
for rows.Next() {
|
||||
var r TemplateRole
|
||||
if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, r)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]int64, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT volunteer_id FROM shift_template_volunteers WHERE template_id = ?`, templateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int64, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]InstanceVolunteer, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT siv.instance_id, siv.volunteer_id, v.name, siv.confirmed, siv.confirmed_at
|
||||
FROM shift_instance_volunteers siv
|
||||
JOIN volunteers v ON v.id = siv.volunteer_id
|
||||
WHERE siv.instance_id = ?
|
||||
ORDER BY v.name`, instanceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
vols := make([]InstanceVolunteer, 0)
|
||||
for rows.Next() {
|
||||
var iv InstanceVolunteer
|
||||
var confirmedAt sql.NullString
|
||||
if err := rows.Scan(&iv.InstanceID, &iv.VolunteerID, &iv.Name, &iv.Confirmed, &confirmedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if confirmedAt.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", confirmedAt.String)
|
||||
iv.ConfirmedAt = &t
|
||||
}
|
||||
vols = append(vols, iv)
|
||||
}
|
||||
return vols, rows.Err()
|
||||
}
|
||||
|
||||
func upsertTemplateRoles(ctx context.Context, tx *sql.Tx, templateID int64, roles []TemplateRole) error {
|
||||
for _, r := range roles {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_template_roles (template_id, role_name, count) VALUES (?, ?, ?)`,
|
||||
templateID, r.RoleName, r.Count,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert role: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertTemplateVolunteers(ctx context.Context, tx *sql.Tx, templateID int64, volunteerIDs []int64) error {
|
||||
for _, vid := range volunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT IGNORE INTO shift_template_volunteers (template_id, volunteer_id) VALUES (?, ?)`,
|
||||
templateID, vid,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert template volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type instanceScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanInstance(r instanceScanner) (*ShiftInstance, error) {
|
||||
var inst ShiftInstance
|
||||
var templateID sql.NullInt64
|
||||
var createdAt, updatedAt string
|
||||
if err := r.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
|
||||
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if templateID.Valid {
|
||||
inst.TemplateID = &templateID.Int64
|
||||
}
|
||||
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if inst.Volunteers == nil {
|
||||
inst.Volunteers = []InstanceVolunteer{}
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
func scanInstanceRow(row *sql.Row) (*ShiftInstance, error) {
|
||||
var inst ShiftInstance
|
||||
var templateID sql.NullInt64
|
||||
var createdAt, updatedAt string
|
||||
if err := row.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
|
||||
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if templateID.Valid {
|
||||
inst.TemplateID = &templateID.Int64
|
||||
}
|
||||
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if inst.Volunteers == nil {
|
||||
inst.Volunteers = []InstanceVolunteer{}
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
func daysIn(year, month int) int {
|
||||
return time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.unsupervised.ca/walkies/internal/notification"
|
||||
"git.unsupervised.ca/walkies/internal/schedule"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"git.unsupervised.ca/walkies/internal/setup"
|
||||
"git.unsupervised.ca/walkies/internal/timeoff"
|
||||
"git.unsupervised.ca/walkies/internal/volunteer"
|
||||
)
|
||||
@@ -22,8 +23,11 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
volunteerStore := volunteer.NewStore(db)
|
||||
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
||||
|
||||
notificationStore := notification.NewStore(db)
|
||||
notificationHandler := notification.NewHandler(notificationStore)
|
||||
|
||||
scheduleStore := schedule.NewStore(db)
|
||||
scheduleHandler := schedule.NewHandler(scheduleStore)
|
||||
scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore)
|
||||
|
||||
timeoffStore := timeoff.NewStore(db)
|
||||
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
||||
@@ -31,8 +35,8 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
checkinStore := checkin.NewStore(db)
|
||||
checkinHandler := checkin.NewHandler(checkinStore)
|
||||
|
||||
notificationStore := notification.NewStore(db)
|
||||
notificationHandler := notification.NewHandler(notificationStore)
|
||||
setupStore := setup.NewStore(db)
|
||||
setupHandler := setup.NewHandler(setupStore, authSvc)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimiddleware.Logger)
|
||||
@@ -45,6 +49,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
r.Post("/auth/login", volunteerHandler.Login)
|
||||
r.Post("/auth/activate", volunteerHandler.Activate)
|
||||
|
||||
// Public setup endpoints (self-disabling once first user exists)
|
||||
r.Get("/setup/status", setupHandler.Status)
|
||||
r.Post("/setup/admin", setupHandler.CreateAdmin)
|
||||
|
||||
// Protected routes
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Authenticate(authSvc))
|
||||
@@ -56,11 +64,19 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
r.Put("/volunteers/{id}", volunteerHandler.Update)
|
||||
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
|
||||
|
||||
// Schedules
|
||||
r.Get("/schedules", scheduleHandler.List)
|
||||
r.Post("/schedules", scheduleHandler.Create)
|
||||
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update)
|
||||
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete)
|
||||
// Shift templates (admin only)
|
||||
r.Get("/shift-templates", scheduleHandler.ListTemplates)
|
||||
r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate)
|
||||
r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate)
|
||||
r.With(middleware.RequireAdmin).Delete("/shift-templates/{id}", scheduleHandler.DeleteTemplate)
|
||||
|
||||
// Shift instances
|
||||
r.Get("/shifts", scheduleHandler.ListInstances)
|
||||
r.With(middleware.RequireAdmin).Post("/shifts/generate", scheduleHandler.GenerateInstances)
|
||||
r.With(middleware.RequireAdmin).Post("/shifts/publish", scheduleHandler.PublishMonth)
|
||||
r.With(middleware.RequireAdmin).Post("/shifts/unpublish", scheduleHandler.UnpublishMonth)
|
||||
r.With(middleware.RequireAdmin).Put("/shifts/{id}", scheduleHandler.UpdateInstance)
|
||||
r.Post("/shifts/{id}/confirm", scheduleHandler.ConfirmShift)
|
||||
|
||||
// Time off
|
||||
r.Get("/timeoff", timeoffHandler.List)
|
||||
@@ -78,8 +94,25 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
||||
})
|
||||
})
|
||||
|
||||
// Serve static React app for all other routes
|
||||
r.Handle("/*", http.FileServer(http.Dir(staticDir)))
|
||||
// Serve static React app for all other routes, with SPA fallback
|
||||
r.Handle("/*", spaHandler(staticDir))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// spaHandler serves static files from dir, falling back to index.html for
|
||||
// paths that don't match a file on disk (so client-side routing works).
|
||||
func spaHandler(dir string) http.HandlerFunc {
|
||||
fs := http.Dir(dir)
|
||||
fileServer := http.FileServer(fs)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to open the requested path as a static file.
|
||||
if f, err := fs.Open(r.URL.Path); err == nil {
|
||||
f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Not a real file — serve index.html and let React Router handle it.
|
||||
http.ServeFile(w, r, dir+"/index.html")
|
||||
}
|
||||
}
|
||||
|
||||
84
internal/setup/handler.go
Normal file
84
internal/setup/handler.go
Normal 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})
|
||||
}
|
||||
168
internal/setup/handler_test.go
Normal file
168
internal/setup/handler_test.go
Normal 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
68
internal/setup/setup.go
Normal 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
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
import { api } from './api';
|
||||
|
||||
// Mock all API calls so the app renders without a backend
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
getSetupStatus: jest.fn(),
|
||||
createSetupAdmin: jest.fn(),
|
||||
listVolunteers: jest.fn().mockResolvedValue([]),
|
||||
listSchedules: jest.fn().mockResolvedValue([]),
|
||||
listTimeOff: jest.fn().mockResolvedValue([]),
|
||||
@@ -14,16 +17,27 @@ jest.mock('./api', () => ({
|
||||
OPERATIONAL_ROLES: [],
|
||||
}));
|
||||
|
||||
test('renders login page when unauthenticated', () => {
|
||||
const mockGetSetupStatus = api.getSetupStatus as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
render(<App />);
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
|
||||
mockGetSetupStatus.mockResolvedValue({ needs_setup: false });
|
||||
});
|
||||
|
||||
test('login page has email and password fields', () => {
|
||||
localStorage.clear();
|
||||
test('renders login page when unauthenticated and setup done', async () => {
|
||||
render(<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.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();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './auth';
|
||||
import { api } from './api';
|
||||
import Login from './pages/Login';
|
||||
import Activate from './pages/Activate';
|
||||
import Setup from './pages/Setup';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Schedules from './pages/Schedules';
|
||||
import TimeOff from './pages/TimeOff';
|
||||
@@ -34,6 +36,7 @@ function ProtectedLayout() {
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/schedules/templates" element={<Schedules />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/timeoff" element={<TimeOff />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
@@ -50,15 +53,57 @@ function LoginRoute() {
|
||||
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() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<SetupGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route path="/activate" element={<Activate />} />
|
||||
<Route path="/*" element={<ProtectedLayout />} />
|
||||
</Routes>
|
||||
</SetupGate>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
102
web/src/api.ts
102
web/src/api.ts
@@ -22,6 +22,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Setup
|
||||
getSetupStatus: () =>
|
||||
request<{ needs_setup: boolean }>('GET', '/setup/status'),
|
||||
createSetupAdmin: (data: { name: string; email: string; password: string }) =>
|
||||
request<{ token: string }>('POST', '/setup/admin', data),
|
||||
|
||||
// Auth
|
||||
login: (email: string, password: string) =>
|
||||
request<{ token: string }>('POST', '/auth/login', { email, password }),
|
||||
@@ -38,12 +44,26 @@ export const api = {
|
||||
resendInvite: (id: number) =>
|
||||
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
|
||||
|
||||
// Schedules
|
||||
listSchedules: () => request<Schedule[]>('GET', '/schedules'),
|
||||
createSchedule: (data: CreateScheduleInput) => request<Schedule>('POST', '/schedules', data),
|
||||
updateSchedule: (id: number, data: Partial<CreateScheduleInput>) =>
|
||||
request<Schedule>('PUT', `/schedules/${id}`, data),
|
||||
deleteSchedule: (id: number) => request<void>('DELETE', `/schedules/${id}`),
|
||||
// Shift templates
|
||||
listShiftTemplates: () => request<ShiftTemplate[]>('GET', '/shift-templates'),
|
||||
createShiftTemplate: (data: CreateShiftTemplateInput) =>
|
||||
request<ShiftTemplate>('POST', '/shift-templates', data),
|
||||
updateShiftTemplate: (id: number, data: Partial<CreateShiftTemplateInput>) =>
|
||||
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
|
||||
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
||||
@@ -104,23 +124,67 @@ export interface UpdateVolunteerInput {
|
||||
admin_notes?: string;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
export interface TemplateRole {
|
||||
id?: number;
|
||||
template_id?: number;
|
||||
role_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ShiftTemplate {
|
||||
id: number;
|
||||
volunteer_id: number;
|
||||
title: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
notes?: string;
|
||||
name: string;
|
||||
day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
min_capacity: number;
|
||||
max_capacity: number;
|
||||
roles: TemplateRole[];
|
||||
volunteer_ids: number[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateScheduleInput {
|
||||
volunteer_id?: number;
|
||||
title: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
notes?: string;
|
||||
export interface CreateShiftTemplateInput {
|
||||
name: string;
|
||||
day_of_week: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
min_capacity: number;
|
||||
max_capacity: number;
|
||||
roles?: TemplateRole[];
|
||||
volunteer_ids?: number[];
|
||||
}
|
||||
|
||||
export interface InstanceVolunteer {
|
||||
instance_id: number;
|
||||
volunteer_id: number;
|
||||
name: string;
|
||||
confirmed: boolean;
|
||||
confirmed_at?: string;
|
||||
}
|
||||
|
||||
export interface ShiftInstance {
|
||||
id: number;
|
||||
template_id?: number;
|
||||
name: string;
|
||||
date: string; // "YYYY-MM-DD"
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
min_capacity: number;
|
||||
max_capacity: number;
|
||||
status: 'draft' | 'published';
|
||||
year: number;
|
||||
month: number;
|
||||
volunteers: InstanceVolunteer[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateShiftInput {
|
||||
volunteer_ids?: number[];
|
||||
min_capacity?: number;
|
||||
max_capacity?: number;
|
||||
}
|
||||
|
||||
export interface TimeOffRequest {
|
||||
@@ -155,7 +219,7 @@ export interface Notification {
|
||||
id: number;
|
||||
volunteer_id: number;
|
||||
message: string;
|
||||
read: boolean;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api, CheckIn, Notification, Schedule } from '../api';
|
||||
import { api, CheckIn, Notification, ShiftInstance } from '../api';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { volunteerID } = useAuth();
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
const now = new Date();
|
||||
const [schedules, setSchedules] = useState<ShiftInstance[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [activeCheckIn, setActiveCheckIn] = useState<CheckIn | null>(null);
|
||||
const [history, setHistory] = useState<CheckIn[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.listSchedules().then(setSchedules).catch(() => {});
|
||||
api.listShifts(now.getFullYear(), now.getMonth() + 1).then(setSchedules).catch(() => {});
|
||||
api.listNotifications().then(setNotifications).catch(() => {});
|
||||
api.getHistory().then(data => {
|
||||
setHistory(data);
|
||||
const active = data.find(c => !c.checked_out_at && c.volunteer_id === volunteerID);
|
||||
setActiveCheckIn(active ?? null);
|
||||
}).catch(() => {});
|
||||
}, [volunteerID]);
|
||||
}, [volunteerID]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function handleCheckIn() {
|
||||
try {
|
||||
@@ -43,15 +44,15 @@ export default function Dashboard() {
|
||||
async function handleMarkRead(id: number) {
|
||||
try {
|
||||
await api.markRead(id);
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const upcomingSchedules = schedules
|
||||
.filter(s => new Date(s.starts_at) >= new Date())
|
||||
.filter(s => new Date(s.date) >= now)
|
||||
.slice(0, 5);
|
||||
|
||||
const unreadNotifications = notifications.filter(n => !n.read);
|
||||
const unreadNotifications = notifications.filter(n => !n.is_read);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -78,7 +79,7 @@ export default function Dashboard() {
|
||||
<ul>
|
||||
{upcomingSchedules.map(s => (
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
@@ -92,9 +93,9 @@ export default function Dashboard() {
|
||||
) : (
|
||||
<ul>
|
||||
{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.read && (
|
||||
{!n.is_read && (
|
||||
<button className="btn-small" onClick={() => handleMarkRead(n.id)}>Mark read</button>
|
||||
)}
|
||||
</li>
|
||||
|
||||
211
web/src/pages/Schedules.test.tsx
Normal file
211
web/src/pages/Schedules.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +1,636 @@
|
||||
import React, { useEffect, useState, FormEvent } from 'react';
|
||||
import { api, Schedule } from '../api';
|
||||
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
api,
|
||||
ShiftTemplate,
|
||||
ShiftInstance,
|
||||
Volunteer,
|
||||
TimeOffRequest,
|
||||
CreateShiftTemplateInput,
|
||||
TemplateRole,
|
||||
OPERATIONAL_ROLES,
|
||||
} from '../api';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
function currentYearMonth() {
|
||||
const now = new Date();
|
||||
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function blankTemplate(): CreateShiftTemplateInput {
|
||||
return {
|
||||
name: '',
|
||||
day_of_week: 1,
|
||||
start_time: '09:00:00',
|
||||
end_time: '17:00:00',
|
||||
min_capacity: 1,
|
||||
max_capacity: 5,
|
||||
roles: [],
|
||||
volunteer_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface TemplateFormProps {
|
||||
initial?: Partial<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() {
|
||||
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 [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(() => {
|
||||
api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.'));
|
||||
}, []);
|
||||
if (isTemplatesView) {
|
||||
api.listShiftTemplates()
|
||||
.then(setTemplates)
|
||||
.catch(() => setError('Could not load templates.'));
|
||||
} else {
|
||||
api.listShifts(year, month)
|
||||
.then(setInstances)
|
||||
.catch(() => setError('Could not load shifts.'));
|
||||
}
|
||||
}, [isTemplatesView, year, month]);
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
function prevMonth() {
|
||||
const m = month === 1 ? 12 : month - 1;
|
||||
const y = month === 1 ? year - 1 : year;
|
||||
goToShifts(y, m);
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
const m = month === 12 ? 1 : month + 1;
|
||||
const y = month === 12 ? year + 1 : year;
|
||||
goToShifts(y, m);
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
setError('');
|
||||
try {
|
||||
const sc = await api.createSchedule(form);
|
||||
setSchedules(prev => [...prev, sc]);
|
||||
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
|
||||
setShowForm(false);
|
||||
const newInstances = await api.generateShifts(year, month);
|
||||
setInstances(newInstances);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!window.confirm('Delete this schedule?')) return;
|
||||
async function handlePublish() {
|
||||
setError('');
|
||||
try {
|
||||
await api.deleteSchedule(id);
|
||||
setSchedules(prev => prev.filter(s => s.id !== id));
|
||||
await api.publishShifts(year, month);
|
||||
const updated = await api.listShifts(year, month);
|
||||
setInstances(updated);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
if (!window.confirm(`Unpublish the ${MONTH_NAMES[month - 1]} ${year} schedule?`)) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.unpublishShifts(year, month);
|
||||
const updated = await api.listShifts(year, month);
|
||||
setInstances(updated);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(id: number) {
|
||||
setError('');
|
||||
try {
|
||||
await api.confirmShift(id);
|
||||
const updated = await api.listShifts(year, month);
|
||||
setInstances(updated);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTemplate(data: CreateShiftTemplateInput) {
|
||||
const t = await api.createShiftTemplate(data);
|
||||
setTemplates(prev => [...prev, t]);
|
||||
setShowTemplateForm(false);
|
||||
}
|
||||
|
||||
async function handleUpdateTemplate(data: CreateShiftTemplateInput) {
|
||||
if (!editingTemplate) return;
|
||||
const t = await api.updateShiftTemplate(editingTemplate.id, data);
|
||||
setTemplates(prev => prev.map(x => (x.id === t.id ? t : x)));
|
||||
setEditingTemplate(null);
|
||||
}
|
||||
|
||||
async function handleDeleteTemplate(id: number) {
|
||||
if (!window.confirm('Delete this template? Existing shifts will not be affected.')) return;
|
||||
setError('');
|
||||
try {
|
||||
await api.deleteShiftTemplate(id);
|
||||
setTemplates(prev => prev.filter(t => t.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleInstanceSaved(updated: ShiftInstance) {
|
||||
setInstances(prev => prev.map(i => (i.id === updated.id ? updated : i)));
|
||||
setEditingInstance(null);
|
||||
}
|
||||
|
||||
const allPublished = instances.length > 0 && instances.every(i => i.status === 'published');
|
||||
const hasDraft = instances.some(i => i.status === 'draft');
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h2>Schedules</h2>
|
||||
{role === 'admin' && (
|
||||
<button onClick={() => setShowForm(v => !v)}>
|
||||
{showForm ? 'Cancel' : 'Add Shift'}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button onClick={() => isTemplatesView ? goToShifts(year, month) : goToTemplates()}>
|
||||
{isTemplatesView ? 'View Shifts' : 'Manage Templates'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{showForm && (
|
||||
<form className="card" onSubmit={handleCreate}>
|
||||
<h3>New Shift</h3>
|
||||
<label>
|
||||
Title
|
||||
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
|
||||
</label>
|
||||
<label>
|
||||
Starts At
|
||||
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
||||
</label>
|
||||
<label>
|
||||
Ends At
|
||||
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
{/* ---- Templates view ---- */}
|
||||
{isTemplatesView && role === 'admin' && (
|
||||
<>
|
||||
<div className="page-header" style={{ marginBottom: '1rem' }}>
|
||||
<h3>Shift Templates</h3>
|
||||
<button onClick={() => { setShowTemplateForm(true); setEditingTemplate(null); }}>
|
||||
+ New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTemplateForm && !editingTemplate && (
|
||||
<TemplateForm
|
||||
title="New Template"
|
||||
onSave={handleCreateTemplate}
|
||||
onCancel={() => setShowTemplateForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{schedules.length === 0 ? (
|
||||
<p>No schedules found.</p>
|
||||
{editingTemplate && (
|
||||
<TemplateForm
|
||||
title={`Edit: ${editingTemplate.name}`}
|
||||
initial={editingTemplate}
|
||||
onSave={handleUpdateTemplate}
|
||||
onCancel={() => setEditingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<p>No templates yet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Starts</th>
|
||||
<th>Ends</th>
|
||||
<th>Notes</th>
|
||||
{role === 'admin' && <th>Actions</th>}
|
||||
<th>Name</th>
|
||||
<th>Day</th>
|
||||
<th>Time</th>
|
||||
<th>Capacity</th>
|
||||
<th>Roles</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.title}</td>
|
||||
<td>{new Date(s.starts_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.ends_at).toLocaleString()}</td>
|
||||
<td>{s.notes ?? '—'}</td>
|
||||
{role === 'admin' && (
|
||||
{templates.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.name}</td>
|
||||
<td>{DAY_NAMES[t.day_of_week]}</td>
|
||||
<td>{t.start_time.slice(0, 5)}–{t.end_time.slice(0, 5)}</td>
|
||||
<td>{t.min_capacity}–{t.max_capacity}</td>
|
||||
<td>{(t.roles ?? []).map(r => `${r.count}× ${r.role_name}`).join(', ') || '—'}</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>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
104
web/src/pages/Setup.test.tsx
Normal file
104
web/src/pages/Setup.test.tsx
Normal 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
77
web/src/pages/Setup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user