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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user