Files
walkies/internal/schedule/handler.go
James Griffin 07f11fa94e Implement time off management (Issue #3)
Add full time-off lifecycle: create/edit/delete with shift conflict
detection, auto-removal from conflicting shifts with admin notification,
shift restoration on admin delete, and hard block on assigning volunteers
with approved time off to shifts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 10:54:12 -03:00

359 lines
12 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
}
// TimeOffChecker checks whether a volunteer has approved time off on a date (FR-T06).
type TimeOffChecker interface {
HasApprovedTimeOff(ctx context.Context, volunteerID int64, date string) (bool, error)
}
type Handler struct {
store Storer
notifier Notifier
timeOffChecker TimeOffChecker
}
// 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, timeOffChecker TimeOffChecker) *Handler {
return &Handler{store: store, notifier: notifier, timeOffChecker: timeOffChecker}
}
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, notifier Notifier, timeOffChecker TimeOffChecker) *Handler {
return &Handler{store: store, notifier: notifier, timeOffChecker: timeOffChecker}
}
// ---------------------------------------------------------------------------
// 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
}
// FR-T06: Block assigning volunteers with approved time off on the shift date
if in.VolunteerIDs != nil && h.timeOffChecker != nil {
existing, getErr := h.store.GetInstance(r.Context(), id)
if getErr != nil && !errors.Is(getErr, ErrNotFound) {
respond.Error(w, http.StatusInternalServerError, "could not get shift")
return
}
if existing != nil {
for _, vid := range *in.VolunteerIDs {
hasTimeOff, checkErr := h.timeOffChecker.HasApprovedTimeOff(r.Context(), vid, existing.Date)
if checkErr != nil {
respond.Error(w, http.StatusInternalServerError, "could not check time off")
return
}
if hasTimeOff {
respond.Error(w, http.StatusConflict,
fmt.Sprintf("Volunteer %d has approved time off on %s. Remove the time off first.", vid, existing.Date))
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
}