Implement Issue #2: Scheduling & Publishing
Some checks failed
CI / Go tests & lint (push) Successful in 1m42s
CI / Frontend tests & type-check (push) Failing after 1m38s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 32s

Replaces stub schedule CRUD with full shift template + instance system.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:40:41 -03:00
parent 96a363d28f
commit fc88b8f005
9 changed files with 2168 additions and 233 deletions

View File

@@ -1,99 +1,329 @@
package schedule
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
)
// Notifier is the subset of notification.Store the handler needs.
type Notifier interface {
CreateNotification(ctx context.Context, volunteerID int64, message string) error
}
type Handler struct {
store *Store
store Storer
notifier Notifier
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
// Storer is the interface the Handler depends on.
type Storer interface {
CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error)
GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error)
ListTemplates(ctx context.Context) ([]ShiftTemplate, error)
UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error)
DeleteTemplate(ctx context.Context, id int64) error
GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error)
ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error)
GetInstance(ctx context.Context, id int64) (*ShiftInstance, error)
UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (*ShiftInstance, []int64, error)
PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error)
UnpublishMonth(ctx context.Context, year, month int) ([]int64, error)
ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error
}
// GET /api/v1/schedules
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
volunteerID := int64(0)
if claims.Role != "admin" {
volunteerID = claims.VolunteerID
}
schedules, err := h.store.List(r.Context(), volunteerID)
func NewHandler(store *Store, notifier Notifier) *Handler {
return &Handler{store: store, notifier: notifier}
}
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
return &Handler{store: store, notifier: notifier}
}
// ---------------------------------------------------------------------------
// Template handlers
// ---------------------------------------------------------------------------
// GET /api/v1/shift-templates
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.store.ListTemplates(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
respond.Error(w, http.StatusInternalServerError, "could not list templates")
return
}
if schedules == nil {
schedules = []Schedule{}
if templates == nil {
templates = []ShiftTemplate{}
}
respond.JSON(w, http.StatusOK, schedules)
respond.JSON(w, http.StatusOK, templates)
}
// POST /api/v1/schedules
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CreateInput
// POST /api/v1/shift-templates
func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var in CreateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if claims.Role != "admin" {
in.VolunteerID = claims.VolunteerID
}
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
if in.Name == "" || in.StartTime == "" || in.EndTime == "" {
respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
return
}
sc, err := h.store.Create(r.Context(), in)
if in.MinCapacity <= 0 {
in.MinCapacity = 1
}
if in.MaxCapacity < in.MinCapacity {
in.MaxCapacity = in.MinCapacity
}
t, err := h.store.CreateTemplate(r.Context(), in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
respond.Error(w, http.StatusInternalServerError, "could not create template")
return
}
respond.JSON(w, http.StatusCreated, sc)
respond.JSON(w, http.StatusCreated, t)
}
// PUT /api/v1/schedules/{id}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
// PUT /api/v1/shift-templates/{id}
func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
var in UpdateInput
var in UpdateTemplateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
sc, err := h.store.Update(r.Context(), id, in)
t, err := h.store.UpdateTemplate(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "schedule not found")
respond.Error(w, http.StatusNotFound, "template not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
respond.Error(w, http.StatusInternalServerError, "could not update template")
return
}
respond.JSON(w, http.StatusOK, sc)
respond.JSON(w, http.StatusOK, t)
}
// DELETE /api/v1/schedules/{id}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
// DELETE /api/v1/shift-templates/{id}
func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
if err := h.store.DeleteTemplate(r.Context(), id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete template")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Instance handlers
// ---------------------------------------------------------------------------
// GET /api/v1/shifts?year=2026&month=4
func (h *Handler) ListInstances(w http.ResponseWriter, r *http.Request) {
year, month := parseYearMonth(r)
claims := middleware.ClaimsFromContext(r.Context())
volunteerID := int64(0)
if claims.Role != "admin" {
volunteerID = claims.VolunteerID
}
instances, err := h.store.ListInstances(r.Context(), year, month, volunteerID)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list shifts")
return
}
if instances == nil {
instances = []ShiftInstance{}
}
respond.JSON(w, http.StatusOK, instances)
}
// POST /api/v1/shifts/generate body: {"year":2026,"month":4}
func (h *Handler) GenerateInstances(w http.ResponseWriter, r *http.Request) {
var body struct {
Year int `json:"year"`
Month int `json:"month"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
return
}
instances, err := h.store.GenerateInstances(r.Context(), body.Year, body.Month)
if errors.Is(err, ErrAlreadyExists) {
respond.Error(w, http.StatusConflict, "shifts already generated for this period")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not generate shifts")
return
}
respond.JSON(w, http.StatusCreated, instances)
}
// POST /api/v1/shifts/publish body: {"year":2026,"month":4}
func (h *Handler) PublishMonth(w http.ResponseWriter, r *http.Request) {
var body struct {
Year int `json:"year"`
Month int `json:"month"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
return
}
byVol, err := h.store.PublishMonth(r.Context(), body.Year, body.Month)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not publish schedule")
return
}
// Notify each affected volunteer (FR-S04)
mn := time.Month(body.Month).String()
for vid, shifts := range byVol {
msg := fmt.Sprintf("Your schedule for %s %d has been published. You have %d shift(s).",
mn, body.Year, len(shifts))
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
}
respond.JSON(w, http.StatusOK, map[string]any{
"year": body.Year,
"month": body.Month,
})
}
// POST /api/v1/shifts/unpublish body: {"year":2026,"month":4}
func (h *Handler) UnpublishMonth(w http.ResponseWriter, r *http.Request) {
var body struct {
Year int `json:"year"`
Month int `json:"month"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
return
}
volunteerIDs, err := h.store.UnpublishMonth(r.Context(), body.Year, body.Month)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not unpublish schedule")
return
}
// Notify affected volunteers (FR-S05)
mn := time.Month(body.Month).String()
for _, vid := range volunteerIDs {
msg := fmt.Sprintf("The schedule for %s %d has been retracted.", mn, body.Year)
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
}
respond.JSON(w, http.StatusOK, map[string]any{
"year": body.Year,
"month": body.Month,
})
}
// PUT /api/v1/shifts/{id}
func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
var in UpdateInstanceInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
inst, added, err := h.store.UpdateInstance(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "shift not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update shift")
return
}
// Notify all volunteers on a published shift of the change (FR-S09)
if inst.Status == "published" && in.VolunteerIDs != nil {
for _, v := range inst.Volunteers {
msg := fmt.Sprintf("Your shift on %s (%s%s) has been updated. Please re-confirm.", inst.Date, inst.StartTime, inst.EndTime)
h.notifier.CreateNotification(r.Context(), v.VolunteerID, msg) //nolint:errcheck
}
}
// Notify only newly added volunteers (FR-S10)
if inst.Status == "published" && len(added) > 0 {
for _, vid := range added {
msg := fmt.Sprintf("You have been added to a shift on %s (%s%s).", inst.Date, inst.StartTime, inst.EndTime)
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
}
}
respond.JSON(w, http.StatusOK, inst)
}
// POST /api/v1/shifts/{id}/confirm
func (h *Handler) ConfirmShift(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if err := h.store.ConfirmShift(r.Context(), id, claims.VolunteerID); err != nil {
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "shift assignment not found")
return
}
respond.Error(w, http.StatusInternalServerError, "could not confirm shift")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func parseYearMonth(r *http.Request) (year, month int) {
now := time.Now()
year = now.Year()
month = int(now.Month())
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil {
year = y
}
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
month = m
}
return year, month
}