Files
walkies/internal/schedule/handler.go
James Griffin fc88b8f005
Some checks failed
CI / Go tests & lint (push) Successful in 1m42s
CI / Frontend tests & type-check (push) Failing after 1m38s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 32s
Implement Issue #2: Scheduling & Publishing
Replaces stub schedule CRUD with full shift template + instance system.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:40:41 -03:00

330 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Storer
notifier Notifier
}
// 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
}
func NewHandler(store *Store, notifier Notifier) *Handler {
return &Handler{store: store, notifier: notifier}
}
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
return &Handler{store: store, notifier: notifier}
}
// ---------------------------------------------------------------------------
// Template handlers
// ---------------------------------------------------------------------------
// GET /api/v1/shift-templates
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.store.ListTemplates(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list templates")
return
}
if templates == nil {
templates = []ShiftTemplate{}
}
respond.JSON(w, http.StatusOK, templates)
}
// 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 in.Name == "" || in.StartTime == "" || in.EndTime == "" {
respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
return
}
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 template")
return
}
respond.JSON(w, http.StatusCreated, t)
}
// 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 UpdateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
t, err := h.store.UpdateTemplate(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "template not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update template")
return
}
respond.JSON(w, http.StatusOK, t)
}
// 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.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
}