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 }