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>
This commit is contained in:
@@ -1,22 +1,56 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
// Notifier is the subset of notification.Store the handler needs.
|
||||
type Notifier interface {
|
||||
CreateNotification(ctx context.Context, volunteerID int64, message string) error
|
||||
}
|
||||
|
||||
func NewHandler(store *Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
// 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
|
||||
@@ -38,6 +72,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -49,14 +86,181 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
|
||||
return
|
||||
}
|
||||
req, err := h.store.Create(r.Context(), claims.VolunteerID, in)
|
||||
|
||||
// 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())
|
||||
@@ -85,3 +289,19 @@ func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user