package timeoff 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 } // AdminLister returns admin volunteer IDs so the handler can notify them. type AdminLister interface { ListAdminIDs(ctx context.Context) ([]int64, error) } // Storer is the interface the Handler depends on. type Storer interface { Create(ctx context.Context, volunteerID int64, in CreateInput) (*Request, error) GetByID(ctx context.Context, id int64) (*Request, error) List(ctx context.Context, volunteerID int64) ([]Request, error) Review(ctx context.Context, id, reviewerID int64, status string) (*Request, error) Update(ctx context.Context, id int64, in UpdateInput) (*Request, error) Delete(ctx context.Context, id int64) error ConflictingShifts(ctx context.Context, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) RemoveFromConflictingShifts(ctx context.Context, timeOffID, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) RemovedShiftsForTimeOff(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) RestoreRemovedShifts(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) } type Handler struct { store Storer notifier Notifier adminLister AdminLister } func NewHandler(store *Store, notifier Notifier, adminLister AdminLister) *Handler { return &Handler{store: store, notifier: notifier, adminLister: adminLister} } // NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing. func NewHandlerFromInterfaces(store Storer, notifier Notifier, adminLister AdminLister) *Handler { return &Handler{store: store, notifier: notifier, adminLister: adminLister} } // GET /api/v1/timeoff 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 } requests, err := h.store.List(r.Context(), volunteerID) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not list time off requests") return } if requests == nil { requests = []Request{} } respond.JSON(w, http.StatusOK, requests) } // POST /api/v1/timeoff // Creates time off with status "approved". If confirm_conflicts is true and the // volunteer is assigned to shifts in the date range, they are auto-removed and // admins are notified (FR-T03). func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) var in CreateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } if in.StartsAt == "" || in.EndsAt == "" { respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required") return } // Determine target volunteer (admin can create for others, FR-T05) targetVolunteerID := claims.VolunteerID if in.VolunteerID > 0 && claims.Role == "admin" { targetVolunteerID = in.VolunteerID } // Check for conflicting shifts conflicts, err := h.store.ConflictingShifts(r.Context(), targetVolunteerID, in.StartsAt, in.EndsAt) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not check conflicts") return } // If there are conflicts and the user hasn't confirmed, return them if len(conflicts) > 0 && !in.ConfirmConflicts { respond.JSON(w, http.StatusConflict, map[string]any{ "message": "Time off conflicts with assigned shifts. Confirm to proceed.", "conflicts": conflicts, }) return } // Create with auto-approved status req, err := h.store.Create(r.Context(), targetVolunteerID, in) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not create time off request") return } // Auto-approve req, err = h.store.Review(r.Context(), req.ID, claims.VolunteerID, "approved") if err != nil { respond.Error(w, http.StatusInternalServerError, "could not approve time off request") return } // Remove from conflicting shifts (FR-T03) if len(conflicts) > 0 { removed, err := h.store.RemoveFromConflictingShifts(r.Context(), req.ID, targetVolunteerID, in.StartsAt, in.EndsAt) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not remove from conflicting shifts") return } // Notify admins h.notifyAdmins(r.Context(), targetVolunteerID, removed) } respond.JSON(w, http.StatusCreated, req) } // PUT /api/v1/timeoff/{id} // Volunteers can edit their own future time off (FR-T02). Admins can edit any (FR-T05). func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid id") return } existing, err := h.store.GetByID(r.Context(), id) if errors.Is(err, ErrNotFound) { respond.Error(w, http.StatusNotFound, "time off request not found") return } if err != nil { respond.Error(w, http.StatusInternalServerError, "could not get time off request") return } // Permission: volunteers can only edit their own future time off if claims.Role != "admin" { if existing.VolunteerID != claims.VolunteerID { respond.Error(w, http.StatusForbidden, "cannot edit another volunteer's time off") return } if !existing.StartsAt.After(time.Now()) { respond.Error(w, http.StatusForbidden, "cannot edit past time off") return } } var in UpdateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } if in.StartsAt == "" || in.EndsAt == "" { respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required") return } req, err := h.store.Update(r.Context(), id, in) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not update time off request") return } respond.JSON(w, http.StatusOK, req) } // DELETE /api/v1/timeoff/{id} // Volunteers can delete their own future time off (FR-T02). // Admin delete restores the volunteer to previously removed shifts (FR-T04). func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid id") return } existing, err := h.store.GetByID(r.Context(), id) if errors.Is(err, ErrNotFound) { respond.Error(w, http.StatusNotFound, "time off request not found") return } if err != nil { respond.Error(w, http.StatusInternalServerError, "could not get time off request") return } // Permission: volunteers can only delete their own future time off if claims.Role != "admin" { if existing.VolunteerID != claims.VolunteerID { respond.Error(w, http.StatusForbidden, "cannot delete another volunteer's time off") return } if !existing.StartsAt.After(time.Now()) { respond.Error(w, http.StatusForbidden, "cannot delete past time off") return } } // If admin is deleting, restore volunteer to removed shifts (FR-T04) var restored []ConflictingShift if claims.Role == "admin" { restored, err = h.store.RestoreRemovedShifts(r.Context(), id) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not restore shifts") return } } if err := h.store.Delete(r.Context(), id); err != nil { respond.Error(w, http.StatusInternalServerError, "could not delete time off request") return } respond.JSON(w, http.StatusOK, map[string]any{ "deleted": true, "restored_shifts": restored, }) } // GET /api/v1/timeoff/{id}/shifts // Returns shifts that were removed due to this time-off request (preview for FR-T04). func (h *Handler) RemovedShifts(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 } shifts, err := h.store.RemovedShiftsForTimeOff(r.Context(), id) if err != nil { respond.Error(w, http.StatusInternalServerError, "could not get removed shifts") return } if shifts == nil { shifts = []ConflictingShift{} } respond.JSON(w, http.StatusOK, shifts) } // PUT /api/v1/timeoff/{id}/review func (h *Handler) Review(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond.Error(w, http.StatusBadRequest, "invalid id") return } var in ReviewInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } if in.Status != "approved" && in.Status != "rejected" { respond.Error(w, http.StatusBadRequest, "status must be 'approved' or 'rejected'") return } req, err := h.store.Review(r.Context(), id, claims.VolunteerID, in.Status) if errors.Is(err, ErrNotFound) { respond.Error(w, http.StatusNotFound, "time off request not found") return } if err != nil { respond.Error(w, http.StatusInternalServerError, "could not review time off request") return } respond.JSON(w, http.StatusOK, req) } // notifyAdmins sends a notification to all admin users about a volunteer's // shift removals due to time off (FR-T03). func (h *Handler) notifyAdmins(ctx context.Context, volunteerID int64, removed []ConflictingShift) { if len(removed) == 0 { return } adminIDs, err := h.adminLister.ListAdminIDs(ctx) if err != nil { return } msg := fmt.Sprintf("Volunteer %d has been removed from %d shift(s) due to time off.", volunteerID, len(removed)) for _, aid := range adminIDs { h.notifier.CreateNotification(ctx, aid, msg) //nolint:errcheck } }