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>
359 lines
12 KiB
Go
359 lines
12 KiB
Go
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
|
||
}
|