diff --git a/internal/db/schema.go b/internal/db/schema.go index 0c33f41..f3bb766 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -134,5 +134,15 @@ var statements = []string{ FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, INDEX idx_instance_id (instance_id), INDEX idx_volunteer_id (volunteer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS time_off_removed_shifts ( + id INT AUTO_INCREMENT PRIMARY KEY, + time_off_id INT NOT NULL, + instance_id INT NOT NULL, + volunteer_id INT NOT NULL, + FOREIGN KEY (time_off_id) REFERENCES time_off_requests(id) ON DELETE CASCADE, + FOREIGN KEY (instance_id) REFERENCES shift_instances(id) ON DELETE CASCADE, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + INDEX idx_time_off_id (time_off_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } diff --git a/internal/schedule/handler.go b/internal/schedule/handler.go index 40b2757..1cf8a31 100644 --- a/internal/schedule/handler.go +++ b/internal/schedule/handler.go @@ -19,9 +19,15 @@ 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 + store Storer + notifier Notifier + timeOffChecker TimeOffChecker } // Storer is the interface the Handler depends on. @@ -41,13 +47,13 @@ type Storer interface { ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error } -func NewHandler(store *Store, notifier Notifier) *Handler { - return &Handler{store: store, notifier: notifier} +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) *Handler { - return &Handler{store: store, notifier: notifier} +func NewHandlerFromInterfaces(store Storer, notifier Notifier, timeOffChecker TimeOffChecker) *Handler { + return &Handler{store: store, notifier: notifier, timeOffChecker: timeOffChecker} } // --------------------------------------------------------------------------- @@ -263,6 +269,29 @@ func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) { 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") diff --git a/internal/schedule/handler_test.go b/internal/schedule/handler_test.go index dd7674b..5a7e3f3 100644 --- a/internal/schedule/handler_test.go +++ b/internal/schedule/handler_test.go @@ -200,7 +200,7 @@ func do(t *testing.T, router http.Handler, method, path, body, token string) *ht func TestListTemplates_ReturnsEmpty(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -219,7 +219,7 @@ func TestListTemplates_ReturnsEmpty(t *testing.T) { func TestCreateTemplate_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") @@ -235,7 +235,7 @@ func TestCreateTemplate_AdminOnly(t *testing.T) { func TestCreateTemplate_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -256,7 +256,7 @@ func TestCreateTemplate_Success(t *testing.T) { func TestCreateTemplate_MissingFields(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -271,7 +271,7 @@ func TestCreateTemplate_MissingFields(t *testing.T) { func TestDeleteTemplate_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") @@ -285,7 +285,7 @@ func TestDeleteTemplate_AdminOnly(t *testing.T) { func TestDeleteTemplate_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -303,7 +303,7 @@ func TestDeleteTemplate_Success(t *testing.T) { func TestGenerateInstances_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") @@ -318,7 +318,7 @@ func TestGenerateInstances_AdminOnly(t *testing.T) { func TestGenerateInstances_AlreadyExists(t *testing.T) { store := &fakeStore{genErr: schedule.ErrAlreadyExists} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -337,7 +337,7 @@ func TestGenerateInstances_Success(t *testing.T) { }, } notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -362,7 +362,7 @@ func TestPublishMonth_SendsNotifications(t *testing.T) { }, } notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -380,7 +380,7 @@ func TestPublishMonth_SendsNotifications(t *testing.T) { func TestUnpublishMonth_SendsNotifications(t *testing.T) { store := &fakeStore{unpubResult: []int64{10, 20}} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -398,7 +398,7 @@ func TestUnpublishMonth_SendsNotifications(t *testing.T) { func TestUpdateInstance_AdminOnly(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 2, "volunteer") @@ -424,7 +424,7 @@ func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) { addedVols: []int64{6}, // Bob is newly added } notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 1, "admin") @@ -446,7 +446,7 @@ func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) { func TestConfirmShift_NotAssigned(t *testing.T) { store := &fakeStore{confirmErr: schedule.ErrNotFound} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 5, "volunteer") @@ -460,7 +460,7 @@ func TestConfirmShift_NotAssigned(t *testing.T) { func TestConfirmShift_Success(t *testing.T) { store := &fakeStore{} notifier := &fakeNotifier{} - h := schedule.NewHandlerFromInterfaces(store, notifier) + h := schedule.NewHandlerFromInterfaces(store, notifier, nil) router := newRouter(h) token := jwtForRole(t, 5, "volunteer") diff --git a/internal/server/server.go b/internal/server/server.go index a1f6d01..af46cc9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,11 +26,12 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { notificationStore := notification.NewStore(db) notificationHandler := notification.NewHandler(notificationStore) - scheduleStore := schedule.NewStore(db) - scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore) - timeoffStore := timeoff.NewStore(db) - timeoffHandler := timeoff.NewHandler(timeoffStore) + + scheduleStore := schedule.NewStore(db) + scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore, timeoffStore) + + timeoffHandler := timeoff.NewHandler(timeoffStore, notificationStore, volunteerStore) checkinStore := checkin.NewStore(db) checkinHandler := checkin.NewHandler(checkinStore) @@ -81,7 +82,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { // Time off r.Get("/timeoff", timeoffHandler.List) r.Post("/timeoff", timeoffHandler.Create) + r.Put("/timeoff/{id}", timeoffHandler.Update) + r.Delete("/timeoff/{id}", timeoffHandler.Delete) r.With(middleware.RequireAdmin).Put("/timeoff/{id}/review", timeoffHandler.Review) + r.With(middleware.RequireAdmin).Get("/timeoff/{id}/shifts", timeoffHandler.RemovedShifts) // Check-in / check-out r.Post("/checkin", checkinHandler.CheckIn) diff --git a/internal/timeoff/handler.go b/internal/timeoff/handler.go index 0d4365a..1f1d94c 100644 --- a/internal/timeoff/handler.go +++ b/internal/timeoff/handler.go @@ -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 + } +} diff --git a/internal/timeoff/handler_test.go b/internal/timeoff/handler_test.go new file mode 100644 index 0000000..62cbec6 --- /dev/null +++ b/internal/timeoff/handler_test.go @@ -0,0 +1,521 @@ +package timeoff_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.unsupervised.ca/walkies/internal/auth" + "git.unsupervised.ca/walkies/internal/server/middleware" + "git.unsupervised.ca/walkies/internal/timeoff" + "github.com/go-chi/chi/v5" +) + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +type fakeStore struct { + requests []timeoff.Request + createResult *timeoff.Request + createErr error + getResult *timeoff.Request + getErr error + updateResult *timeoff.Request + updateErr error + deleteErr error + reviewResult *timeoff.Request + reviewErr error + conflicts []timeoff.ConflictingShift + conflictsErr error + removedShifts []timeoff.ConflictingShift + removeErr error + removedForTimeOff []timeoff.ConflictingShift + removedForErr error + restoredShifts []timeoff.ConflictingShift + restoreErr error + + removeCalled bool + restoreCalled bool +} + +func (f *fakeStore) Create(_ context.Context, volunteerID int64, in timeoff.CreateInput) (*timeoff.Request, error) { + if f.createErr != nil { + return nil, f.createErr + } + if f.createResult != nil { + return f.createResult, nil + } + return &timeoff.Request{ + ID: 1, VolunteerID: volunteerID, + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + EndsAt: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + Status: "pending", + }, nil +} + +func (f *fakeStore) GetByID(_ context.Context, id int64) (*timeoff.Request, error) { + if f.getErr != nil { + return nil, f.getErr + } + if f.getResult != nil { + return f.getResult, nil + } + return nil, timeoff.ErrNotFound +} + +func (f *fakeStore) List(_ context.Context, volunteerID int64) ([]timeoff.Request, error) { + if volunteerID > 0 { + var filtered []timeoff.Request + for _, r := range f.requests { + if r.VolunteerID == volunteerID { + filtered = append(filtered, r) + } + } + return filtered, nil + } + return f.requests, nil +} + +func (f *fakeStore) Review(_ context.Context, id, reviewerID int64, status string) (*timeoff.Request, error) { + if f.reviewErr != nil { + return nil, f.reviewErr + } + if f.reviewResult != nil { + return f.reviewResult, nil + } + return &timeoff.Request{ID: id, Status: status}, nil +} + +func (f *fakeStore) Update(_ context.Context, id int64, in timeoff.UpdateInput) (*timeoff.Request, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + if f.updateResult != nil { + return f.updateResult, nil + } + return &timeoff.Request{ID: id, Status: "approved"}, nil +} + +func (f *fakeStore) Delete(_ context.Context, id int64) error { + return f.deleteErr +} + +func (f *fakeStore) ConflictingShifts(_ context.Context, _ int64, _, _ string) ([]timeoff.ConflictingShift, error) { + if f.conflictsErr != nil { + return nil, f.conflictsErr + } + return f.conflicts, nil +} + +func (f *fakeStore) RemoveFromConflictingShifts(_ context.Context, _, _ int64, _, _ string) ([]timeoff.ConflictingShift, error) { + f.removeCalled = true + if f.removeErr != nil { + return nil, f.removeErr + } + return f.removedShifts, nil +} + +func (f *fakeStore) RemovedShiftsForTimeOff(_ context.Context, _ int64) ([]timeoff.ConflictingShift, error) { + if f.removedForErr != nil { + return nil, f.removedForErr + } + return f.removedForTimeOff, nil +} + +func (f *fakeStore) RestoreRemovedShifts(_ context.Context, _ int64) ([]timeoff.ConflictingShift, error) { + f.restoreCalled = true + if f.restoreErr != nil { + return nil, f.restoreErr + } + return f.restoredShifts, nil +} + +type fakeNotifier struct { + notifications []struct { + VolunteerID int64 + Message string + } +} + +func (f *fakeNotifier) CreateNotification(_ context.Context, volunteerID int64, message string) error { + f.notifications = append(f.notifications, struct { + VolunteerID int64 + Message string + }{volunteerID, message}) + return nil +} + +type fakeAdminLister struct { + adminIDs []int64 +} + +func (f *fakeAdminLister) ListAdminIDs(_ context.Context) ([]int64, error) { + return f.adminIDs, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func jwtForRole(t *testing.T, id int64, role string) string { + t.Helper() + svc := auth.NewService(nil, "test-secret") + token, err := svc.IssueToken(id, role) + if err != nil { + t.Fatalf("issue token: %v", err) + } + return token +} + +func newRouter(h *timeoff.Handler) http.Handler { + realAuthSvc := auth.NewService(nil, "test-secret") + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(middleware.Authenticate(realAuthSvc)) + r.Get("/api/v1/timeoff", h.List) + r.Post("/api/v1/timeoff", h.Create) + r.Put("/api/v1/timeoff/{id}", h.Update) + r.Delete("/api/v1/timeoff/{id}", h.Delete) + r.Put("/api/v1/timeoff/{id}/review", + middleware.RequireAdmin(http.HandlerFunc(h.Review)).ServeHTTP) + r.Get("/api/v1/timeoff/{id}/shifts", + middleware.RequireAdmin(http.HandlerFunc(h.RemovedShifts)).ServeHTTP) + }) + return r +} + +func do(t *testing.T, router http.Handler, method, path, body, token string) *httptest.ResponseRecorder { + t.Helper() + var b *bytes.Reader + if body != "" { + b = bytes.NewReader([]byte(body)) + } else { + b = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, b) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +func setup(store *fakeStore) (*timeoff.Handler, http.Handler) { + notifier := &fakeNotifier{} + adminLister := &fakeAdminLister{adminIDs: []int64{1}} + h := timeoff.NewHandlerFromInterfaces(store, notifier, adminLister) + return h, newRouter(h) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +func TestList_VolunteerSeesOwn(t *testing.T) { + store := &fakeStore{ + requests: []timeoff.Request{ + {ID: 1, VolunteerID: 10, Status: "approved"}, + {ID: 2, VolunteerID: 20, Status: "pending"}, + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + w := do(t, router, "GET", "/api/v1/timeoff", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var result []timeoff.Request + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 1 { + t.Fatalf("expected 1 request, got %d", len(result)) + } + if result[0].VolunteerID != 10 { + t.Errorf("expected volunteer_id=10, got %d", result[0].VolunteerID) + } +} + +func TestList_AdminSeesAll(t *testing.T) { + store := &fakeStore{ + requests: []timeoff.Request{ + {ID: 1, VolunteerID: 10, Status: "approved"}, + {ID: 2, VolunteerID: 20, Status: "pending"}, + }, + } + _, router := setup(store) + token := jwtForRole(t, 1, "admin") + w := do(t, router, "GET", "/api/v1/timeoff", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var result []timeoff.Request + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("expected 2 requests, got %d", len(result)) + } +} + +func TestCreate_NoConflicts(t *testing.T) { + store := &fakeStore{} + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","reason":"vacation"}` + w := do(t, router, "POST", "/api/v1/timeoff", body, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } +} + +func TestCreate_ConflictReturns409(t *testing.T) { + store := &fakeStore{ + conflicts: []timeoff.ConflictingShift{ + {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01", StartTime: "08:00:00", EndTime: "12:00:00"}, + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03"}` + w := do(t, router, "POST", "/api/v1/timeoff", body, token) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body) + } + var result map[string]any + json.NewDecoder(w.Body).Decode(&result) + conflicts := result["conflicts"].([]any) + if len(conflicts) != 1 { + t.Errorf("expected 1 conflict, got %d", len(conflicts)) + } +} + +func TestCreate_ConfirmConflictsProceeds(t *testing.T) { + store := &fakeStore{ + conflicts: []timeoff.ConflictingShift{ + {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, + }, + removedShifts: []timeoff.ConflictingShift{ + {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","confirm_conflicts":true}` + w := do(t, router, "POST", "/api/v1/timeoff", body, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + if !store.removeCalled { + t.Error("expected RemoveFromConflictingShifts to be called") + } +} + +func TestCreate_AdminForOtherVolunteer(t *testing.T) { + store := &fakeStore{} + _, router := setup(store) + token := jwtForRole(t, 1, "admin") + + body := `{"starts_at":"2026-05-01","ends_at":"2026-05-03","volunteer_id":20}` + w := do(t, router, "POST", "/api/v1/timeoff", body, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } +} + +func TestUpdate_VolunteerOwnFuture(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 10, Status: "approved", + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + EndsAt: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC), + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04","reason":"extended"}` + w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } +} + +func TestUpdate_VolunteerCannotEditOthers(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 20, Status: "approved", + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04"}` + w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) + } +} + +func TestUpdate_VolunteerCannotEditPast(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 10, Status: "approved", + StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"starts_at":"2026-05-02","ends_at":"2026-05-04"}` + w := do(t, router, "PUT", "/api/v1/timeoff/1", body, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) + } +} + +func TestDelete_VolunteerOwnFuture(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 10, Status: "approved", + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } +} + +func TestDelete_VolunteerCannotDeleteOthers(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 20, Status: "approved", + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + }, + } + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body) + } +} + +func TestDelete_AdminRestoresShifts(t *testing.T) { + store := &fakeStore{ + getResult: &timeoff.Request{ + ID: 1, VolunteerID: 10, Status: "approved", + StartsAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + }, + restoredShifts: []timeoff.ConflictingShift{ + {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, + }, + } + _, router := setup(store) + token := jwtForRole(t, 1, "admin") + + w := do(t, router, "DELETE", "/api/v1/timeoff/1", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if !store.restoreCalled { + t.Error("expected RestoreRemovedShifts to be called") + } + var result map[string]any + json.NewDecoder(w.Body).Decode(&result) + restored := result["restored_shifts"].([]any) + if len(restored) != 1 { + t.Errorf("expected 1 restored shift, got %d", len(restored)) + } +} + +func TestRemovedShifts_AdminOnly(t *testing.T) { + store := &fakeStore{ + removedForTimeOff: []timeoff.ConflictingShift{ + {InstanceID: 100, Name: "Morning Walk", Date: "2026-05-01"}, + }, + } + _, router := setup(store) + + // Volunteer should get 403 + volToken := jwtForRole(t, 10, "volunteer") + w := do(t, router, "GET", "/api/v1/timeoff/1/shifts", "", volToken) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for volunteer, got %d", w.Code) + } + + // Admin should get 200 + adminToken := jwtForRole(t, 1, "admin") + w = do(t, router, "GET", "/api/v1/timeoff/1/shifts", "", adminToken) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for admin, got %d: %s", w.Code, w.Body) + } + var shifts []timeoff.ConflictingShift + json.NewDecoder(w.Body).Decode(&shifts) + if len(shifts) != 1 { + t.Errorf("expected 1 shift, got %d", len(shifts)) + } +} + +func TestReview_AdminOnly(t *testing.T) { + store := &fakeStore{} + _, router := setup(store) + + volToken := jwtForRole(t, 10, "volunteer") + body := `{"status":"approved"}` + w := do(t, router, "PUT", "/api/v1/timeoff/1/review", body, volToken) + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for volunteer, got %d", w.Code) + } +} + +func TestReview_InvalidStatus(t *testing.T) { + store := &fakeStore{} + _, router := setup(store) + + adminToken := jwtForRole(t, 1, "admin") + body := `{"status":"maybe"}` + w := do(t, router, "PUT", "/api/v1/timeoff/1/review", body, adminToken) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body) + } +} + +func TestCreate_MissingDates(t *testing.T) { + store := &fakeStore{} + _, router := setup(store) + token := jwtForRole(t, 10, "volunteer") + + body := `{"reason":"vacation"}` + w := do(t, router, "POST", "/api/v1/timeoff", body, token) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} diff --git a/internal/timeoff/timeoff.go b/internal/timeoff/timeoff.go index 2ddc4f2..64e0e52 100644 --- a/internal/timeoff/timeoff.go +++ b/internal/timeoff/timeoff.go @@ -24,6 +24,14 @@ type Request struct { } type CreateInput struct { + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + Reason string `json:"reason"` + VolunteerID int64 `json:"volunteer_id,omitempty"` // admin creating for another volunteer + ConfirmConflicts bool `json:"confirm_conflicts,omitempty"` // acknowledge shift conflicts +} + +type UpdateInput struct { StartsAt string `json:"starts_at"` EndsAt string `json:"ends_at"` Reason string `json:"reason"` @@ -33,6 +41,23 @@ type ReviewInput struct { Status string `json:"status"` // "approved" | "rejected" } +// ConflictingShift is a shift instance that overlaps with a time-off period. +type ConflictingShift struct { + InstanceID int64 `json:"instance_id"` + Name string `json:"name"` + Date string `json:"date"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` +} + +// RemovedShift records that a volunteer was removed from a shift due to time off. +type RemovedShift struct { + ID int64 `json:"id"` + TimeOffID int64 `json:"time_off_id"` + InstanceID int64 `json:"instance_id"` + VolunteerID int64 `json:"volunteer_id"` +} + type Store struct { db *sql.DB } @@ -141,3 +166,207 @@ func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string) } return s.GetByID(ctx, id) } + +// Update edits a time-off request's dates and reason. +func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Request, error) { + _, err := s.db.ExecContext(ctx, + `UPDATE time_off_requests SET starts_at=?, ends_at=?, reason=?, updated_at=NOW() WHERE id=?`, + in.StartsAt, in.EndsAt, in.Reason, id, + ) + if err != nil { + return nil, fmt.Errorf("update time off request: %w", err) + } + return s.GetByID(ctx, id) +} + +// Delete removes a time-off request. +func (s *Store) Delete(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM time_off_requests WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete time off request: %w", err) + } + affected, _ := result.RowsAffected() + if affected == 0 { + return ErrNotFound + } + return nil +} + +// ConflictingShifts returns published shift instances where the volunteer is assigned +// and the shift date falls within the given date range (inclusive). +func (s *Store) ConflictingShifts(ctx context.Context, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT si.id, si.name, si.date, si.start_time, si.end_time + FROM shift_instances si + JOIN shift_instance_volunteers siv ON siv.instance_id = si.id + WHERE siv.volunteer_id = ? + AND si.date >= ? + AND si.date <= ? + ORDER BY si.date`, volunteerID, startsAt, endsAt, + ) + if err != nil { + return nil, fmt.Errorf("query conflicting shifts: %w", err) + } + defer rows.Close() + + var shifts []ConflictingShift + for rows.Next() { + var cs ConflictingShift + if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil { + return nil, err + } + shifts = append(shifts, cs) + } + return shifts, rows.Err() +} + +// RemoveFromConflictingShifts removes the volunteer from shifts that overlap with +// the time-off period and records the removals for later restoration. +func (s *Store) RemoveFromConflictingShifts(ctx context.Context, timeOffID, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) { + conflicts, err := s.ConflictingShifts(ctx, volunteerID, startsAt, endsAt) + if err != nil { + return nil, err + } + if len(conflicts) == 0 { + return nil, nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + for _, c := range conflicts { + // Record the removal + if _, err := tx.ExecContext(ctx, + `INSERT INTO time_off_removed_shifts (time_off_id, instance_id, volunteer_id) VALUES (?, ?, ?)`, + timeOffID, c.InstanceID, volunteerID, + ); err != nil { + return nil, fmt.Errorf("record removal: %w", err) + } + // Remove volunteer from shift + if _, err := tx.ExecContext(ctx, + `DELETE FROM shift_instance_volunteers WHERE instance_id = ? AND volunteer_id = ?`, + c.InstanceID, volunteerID, + ); err != nil { + return nil, fmt.Errorf("remove from shift: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return conflicts, nil +} + +// RemovedShiftsForTimeOff returns shifts from which the volunteer was removed +// due to the given time-off request (for preview before admin deletes time off). +func (s *Store) RemovedShiftsForTimeOff(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT si.id, si.name, si.date, si.start_time, si.end_time + FROM time_off_removed_shifts tors + JOIN shift_instances si ON si.id = tors.instance_id + WHERE tors.time_off_id = ? + ORDER BY si.date`, timeOffID, + ) + if err != nil { + return nil, fmt.Errorf("query removed shifts: %w", err) + } + defer rows.Close() + + var shifts []ConflictingShift + for rows.Next() { + var cs ConflictingShift + if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil { + return nil, err + } + shifts = append(shifts, cs) + } + return shifts, rows.Err() +} + +// RestoreRemovedShifts re-adds the volunteer to shifts they were removed from +// when the given time-off request is deleted (FR-T04). +func (s *Store) RestoreRemovedShifts(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) { + // Get the removals first + rows, err := s.db.QueryContext(ctx, + `SELECT tors.instance_id, tors.volunteer_id, si.name, si.date, si.start_time, si.end_time + FROM time_off_removed_shifts tors + JOIN shift_instances si ON si.id = tors.instance_id + WHERE tors.time_off_id = ?`, timeOffID, + ) + if err != nil { + return nil, fmt.Errorf("query removals: %w", err) + } + defer rows.Close() + + type removal struct { + instanceID int64 + volunteerID int64 + shift ConflictingShift + } + var removals []removal + for rows.Next() { + var r removal + if err := rows.Scan(&r.instanceID, &r.volunteerID, &r.shift.Name, &r.shift.Date, &r.shift.StartTime, &r.shift.EndTime); err != nil { + return nil, err + } + r.shift.InstanceID = r.instanceID + removals = append(removals, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if len(removals) == 0 { + return nil, nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var restored []ConflictingShift + for _, r := range removals { + // Re-add to shift (ignore duplicate if they were re-added manually) + _, err := tx.ExecContext(ctx, + `INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`, + r.instanceID, r.volunteerID, + ) + if err != nil { + return nil, fmt.Errorf("restore to shift: %w", err) + } + restored = append(restored, r.shift) + } + + // Clean up removal records (CASCADE will handle this on time-off delete, + // but we clean up explicitly since we're restoring) + if _, err := tx.ExecContext(ctx, + `DELETE FROM time_off_removed_shifts WHERE time_off_id = ?`, timeOffID, + ); err != nil { + return nil, fmt.Errorf("clean removals: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return restored, nil +} + +// HasApprovedTimeOff checks if a volunteer has approved time off covering the given date. +func (s *Store) HasApprovedTimeOff(ctx context.Context, volunteerID int64, date string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM time_off_requests + WHERE volunteer_id = ? AND status = 'approved' + AND ? >= DATE(starts_at) AND ? <= DATE(ends_at)`, + volunteerID, date, date, + ).Scan(&count) + if err != nil { + return false, fmt.Errorf("check time off: %w", err) + } + return count > 0, nil +} diff --git a/internal/volunteer/volunteer.go b/internal/volunteer/volunteer.go index bf25c71..fa98b4b 100644 --- a/internal/volunteer/volunteer.go +++ b/internal/volunteer/volunteer.go @@ -397,6 +397,25 @@ func (s *Store) RecordLogin(ctx context.Context, id int64) error { return err } +// ListAdminIDs returns the IDs of all active admin users. +func (s *Store) ListAdminIDs(ctx context.Context) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id FROM volunteers WHERE role = 'admin' AND active = 1`) + if err != nil { + return nil, fmt.Errorf("list admin IDs: %w", err) + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/web/src/api.ts b/web/src/api.ts index 51d176e..7241604 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -4,6 +4,16 @@ function getToken(): string | null { return localStorage.getItem('token'); } +class ApiError extends Error { + status: number; + data: any; + constructor(message: string, status: number, data: any) { + super(message); + this.status = status; + this.data = data; + } +} + async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = { 'Content-Type': 'application/json' }; const token = getToken(); @@ -17,10 +27,12 @@ async function request(method: string, path: string, body?: unknown): Promise if (res.status === 204) return undefined as T; const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Request failed'); + if (!res.ok) throw new ApiError(data.error || data.message || 'Request failed', res.status, data); return data as T; } +export { ApiError }; + export const api = { // Setup getSetupStatus: () => @@ -67,9 +79,16 @@ export const api = { // Time off listTimeOff: () => request('GET', '/timeoff'), - createTimeOff: (data: CreateTimeOffInput) => request('POST', '/timeoff', data), + createTimeOff: (data: CreateTimeOffInput & { volunteer_id?: number; confirm_conflicts?: boolean }) => + request('POST', '/timeoff', data), + updateTimeOff: (id: number, data: { starts_at: string; ends_at: string; reason?: string }) => + request('PUT', `/timeoff/${id}`, data), + deleteTimeOff: (id: number) => + request<{ deleted: boolean; restored_shifts: ConflictingShift[] }>('DELETE', `/timeoff/${id}`), reviewTimeOff: (id: number, status: 'approved' | 'rejected') => request('PUT', `/timeoff/${id}/review`, { status }), + getRemovedShifts: (id: number) => + request('GET', `/timeoff/${id}/shifts`), // Check-in / out checkIn: (schedule_id?: number, notes?: string) => @@ -206,6 +225,19 @@ export interface CreateTimeOffInput { reason?: string; } +export interface ConflictingShift { + instance_id: number; + name: string; + date: string; + start_time: string; + end_time: string; +} + +export interface TimeOffConflictResponse { + message: string; + conflicts: ConflictingShift[]; +} + export interface CheckIn { id: number; volunteer_id: number; diff --git a/web/src/pages/TimeOff.test.tsx b/web/src/pages/TimeOff.test.tsx new file mode 100644 index 0000000..a8ef385 --- /dev/null +++ b/web/src/pages/TimeOff.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import TimeOff from './TimeOff'; +import { api, TimeOffRequest, ApiError } from '../api'; +import { AuthProvider } from '../auth'; + +jest.mock('../api', () => { + class MockApiError extends Error { + status: number; + data: any; + constructor(message: string, status: number, data: any) { + super(message); + this.status = status; + this.data = data; + } + } + return { + api: { + listTimeOff: jest.fn(), + createTimeOff: jest.fn(), + updateTimeOff: jest.fn(), + deleteTimeOff: jest.fn(), + reviewTimeOff: jest.fn(), + getRemovedShifts: jest.fn(), + listVolunteers: jest.fn(), + }, + ApiError: MockApiError, + }; +}); + +const mockListTimeOff = api.listTimeOff as jest.Mock; +const mockCreateTimeOff = api.createTimeOff as jest.Mock; +const mockDeleteTimeOff = api.deleteTimeOff as jest.Mock; +const mockReviewTimeOff = api.reviewTimeOff as jest.Mock; +const mockGetRemovedShifts = api.getRemovedShifts as jest.Mock; +const mockListVolunteers = api.listVolunteers as jest.Mock; + +function buildFakeJWT(payload: object): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = btoa(JSON.stringify(payload)); + return `${header}.${body}.fakesig`; +} + +const ADMIN_TOKEN = buildFakeJWT({ volunteer_id: 1, role: 'admin', exp: 9999999999 }); +const VOL_TOKEN = buildFakeJWT({ volunteer_id: 10, role: 'volunteer', exp: 9999999999 }); + +const futureRequest: TimeOffRequest = { + id: 1, + volunteer_id: 10, + starts_at: '2026-06-01T00:00:00Z', + ends_at: '2026-06-03T00:00:00Z', + reason: 'vacation', + status: 'approved', + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', +}; + +const pastRequest: TimeOffRequest = { + id: 2, + volunteer_id: 10, + starts_at: '2020-01-01T00:00:00Z', + ends_at: '2020-01-03T00:00:00Z', + reason: 'sick', + status: 'approved', + created_at: '2020-01-01T00:00:00Z', + updated_at: '2020-01-01T00:00:00Z', +}; + +function renderAsVolunteer() { + localStorage.setItem('token', VOL_TOKEN); + return render( + + + + + , + ); +} + +function renderAsAdmin() { + localStorage.setItem('token', ADMIN_TOKEN); + return render( + + + + + , + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + mockListVolunteers.mockResolvedValue([]); +}); + +describe('TimeOff page', () => { + it('renders empty state', async () => { + mockListTimeOff.mockResolvedValue([]); + renderAsVolunteer(); + await waitFor(() => expect(screen.getByText('No time off requests.')).toBeInTheDocument()); + }); + + it('renders request list for volunteer', async () => { + mockListTimeOff.mockResolvedValue([futureRequest]); + renderAsVolunteer(); + await waitFor(() => expect(screen.getByText('vacation')).toBeInTheDocument()); + expect(screen.getByText('approved')).toBeInTheDocument(); + }); + + it('shows edit and delete buttons for own future time off', async () => { + mockListTimeOff.mockResolvedValue([futureRequest]); + renderAsVolunteer(); + await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('does not show edit/delete for past time off as volunteer', async () => { + mockListTimeOff.mockResolvedValue([pastRequest]); + renderAsVolunteer(); + await waitFor(() => expect(screen.getByText('sick')).toBeInTheDocument()); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + + it('shows create form when button clicked', async () => { + mockListTimeOff.mockResolvedValue([]); + renderAsVolunteer(); + await waitFor(() => expect(screen.getByText('Request Time Off')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Request Time Off')); + expect(screen.getByText('New Request')).toBeInTheDocument(); + }); + + it('creates time off request successfully', async () => { + mockListTimeOff.mockResolvedValue([]); + mockCreateTimeOff.mockResolvedValue(futureRequest); + renderAsVolunteer(); + + fireEvent.click(screen.getByText('Request Time Off')); + fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-06-01' } }); + fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-06-03' } }); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => expect(mockCreateTimeOff).toHaveBeenCalled()); + }); + + it('shows conflict warning on 409 and allows confirmation', async () => { + mockListTimeOff.mockResolvedValue([]); + const { ApiError: MockApiError } = jest.requireMock('../api'); + mockCreateTimeOff + .mockRejectedValueOnce( + new MockApiError('conflict', 409, { + message: 'Time off conflicts with assigned shifts.', + conflicts: [ + { instance_id: 100, name: 'Morning Walk', date: '2026-06-01', start_time: '08:00', end_time: '12:00' }, + ], + }), + ) + .mockResolvedValueOnce(futureRequest); + + renderAsVolunteer(); + fireEvent.click(screen.getByText('Request Time Off')); + fireEvent.change(screen.getByLabelText('From'), { target: { value: '2026-06-01' } }); + fireEvent.change(screen.getByLabelText('To'), { target: { value: '2026-06-03' } }); + fireEvent.click(screen.getByText('Submit')); + + await waitFor(() => expect(screen.getByText(/conflicts with 1 assigned shift/)).toBeInTheDocument()); + expect(screen.getByText(/Morning Walk/)).toBeInTheDocument(); + expect(screen.getByText('Confirm & Submit')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Confirm & Submit')); + await waitFor(() => + expect(mockCreateTimeOff).toHaveBeenLastCalledWith( + expect.objectContaining({ confirm_conflicts: true }), + ), + ); + }); + + it('admin sees volunteer column and approve/reject buttons', async () => { + const pendingReq: TimeOffRequest = { ...futureRequest, status: 'pending', volunteer_id: 10 }; + mockListTimeOff.mockResolvedValue([pendingReq]); + mockListVolunteers.mockResolvedValue([{ id: 10, name: 'Alice' }]); + renderAsAdmin(); + + await waitFor(() => expect(screen.getByText('Volunteer')).toBeInTheDocument()); + expect(screen.getByText('Approve')).toBeInTheDocument(); + expect(screen.getByText('Reject')).toBeInTheDocument(); + }); + + it('admin can approve a request', async () => { + const pendingReq: TimeOffRequest = { ...futureRequest, status: 'pending' }; + mockListTimeOff.mockResolvedValue([pendingReq]); + mockReviewTimeOff.mockResolvedValue({ ...pendingReq, status: 'approved' }); + mockListVolunteers.mockResolvedValue([]); + renderAsAdmin(); + + await waitFor(() => expect(screen.getByText('Approve')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Approve')); + await waitFor(() => expect(mockReviewTimeOff).toHaveBeenCalledWith(1, 'approved')); + }); + + it('admin sees shift restoration preview when deleting', async () => { + mockListTimeOff.mockResolvedValue([futureRequest]); + mockGetRemovedShifts.mockResolvedValue([ + { instance_id: 100, name: 'Morning Walk', date: '2026-06-01', start_time: '08:00', end_time: '12:00' }, + ]); + mockDeleteTimeOff.mockResolvedValue({ deleted: true, restored_shifts: [] }); + mockListVolunteers.mockResolvedValue([{ id: 10, name: 'Alice' }]); + renderAsAdmin(); + + await waitFor(() => expect(screen.getByText('Delete')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Delete')); + + await waitFor(() => + expect(screen.getByText('Delete Time Off — Shift Restoration Preview')).toBeInTheDocument(), + ); + expect(screen.getByText(/Morning Walk/)).toBeInTheDocument(); + }); + + it('admin sees volunteer picker in create form', async () => { + mockListTimeOff.mockResolvedValue([]); + mockListVolunteers.mockResolvedValue([ + { id: 10, name: 'Alice' }, + { id: 20, name: 'Bob' }, + ]); + renderAsAdmin(); + + await waitFor(() => expect(screen.getByText('Request Time Off')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Request Time Off')); + expect(screen.getByText('Volunteer')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); +}); diff --git a/web/src/pages/TimeOff.tsx b/web/src/pages/TimeOff.tsx index 94fa565..949ac86 100644 --- a/web/src/pages/TimeOff.tsx +++ b/web/src/pages/TimeOff.tsx @@ -1,31 +1,96 @@ import React, { useEffect, useState, FormEvent } from 'react'; -import { api, TimeOffRequest } from '../api'; +import { api, ApiError, TimeOffRequest, ConflictingShift, Volunteer } from '../api'; import { useAuth } from '../auth'; export default function TimeOff() { - const { role } = useAuth(); + const { role, volunteerID } = useAuth(); const [requests, setRequests] = useState([]); + const [volunteers, setVolunteers] = useState([]); const [error, setError] = useState(''); const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '' }); + const [form, setForm] = useState({ starts_at: '', ends_at: '', reason: '', volunteer_id: 0 }); + const [editingId, setEditingId] = useState(null); + const [conflicts, setConflicts] = useState(null); + const [deletePreview, setDeletePreview] = useState<{ id: number; shifts: ConflictingShift[] } | null>(null); useEffect(() => { api.listTimeOff().then(setRequests).catch(() => setError('Could not load requests.')); - }, []); + if (role === 'admin') { + api.listVolunteers().then(vols => setVolunteers(vols as Volunteer[])); + } + }, [role]); async function handleCreate(e: FormEvent) { e.preventDefault(); setError(''); try { - const req = await api.createTimeOff(form); - setRequests(prev => [req, ...prev]); - setForm({ starts_at: '', ends_at: '', reason: '' }); - setShowForm(false); + const payload: any = { starts_at: form.starts_at, ends_at: form.ends_at, reason: form.reason }; + if (role === 'admin' && form.volunteer_id > 0) { + payload.volunteer_id = form.volunteer_id; + } + if (conflicts) { + payload.confirm_conflicts = true; + } + const result = await api.createTimeOff(payload); + setRequests(prev => [result as TimeOffRequest, ...prev]); + resetForm(); + } catch (err: any) { + if (err instanceof ApiError && err.status === 409 && err.data?.conflicts) { + setConflicts(err.data.conflicts); + return; + } + setError(err.message); + } + } + + async function handleUpdate(e: FormEvent) { + e.preventDefault(); + if (!editingId) return; + setError(''); + try { + const req = await api.updateTimeOff(editingId, { + starts_at: form.starts_at, + ends_at: form.ends_at, + reason: form.reason, + }); + setRequests(prev => prev.map(r => r.id === editingId ? req : r)); + resetForm(); } catch (err: any) { setError(err.message); } } + async function handleDelete(id: number) { + setError(''); + try { + const result = await api.deleteTimeOff(id); + setRequests(prev => prev.filter(r => r.id !== id)); + setDeletePreview(null); + if (result.restored_shifts?.length > 0) { + setError(`Restored volunteer to ${result.restored_shifts.length} shift(s).`); + } + } catch (err: any) { + setError(err.message); + } + } + + async function handleDeleteClick(id: number) { + if (role === 'admin') { + try { + const shifts = await api.getRemovedShifts(id); + if (shifts.length > 0) { + setDeletePreview({ id, shifts }); + return; + } + } catch { + // If we can't fetch preview, proceed with confirm + } + } + if (window.confirm('Delete this time off request?')) { + handleDelete(id); + } + } + async function handleReview(id: number, status: 'approved' | 'rejected') { try { const req = await api.reviewTimeOff(id, status); @@ -35,71 +100,150 @@ export default function TimeOff() { } } + function startEdit(r: TimeOffRequest) { + setEditingId(r.id); + setForm({ + starts_at: r.starts_at.split('T')[0], + ends_at: r.ends_at.split('T')[0], + reason: r.reason ?? '', + volunteer_id: 0, + }); + setShowForm(true); + setConflicts(null); + } + + function resetForm() { + setForm({ starts_at: '', ends_at: '', reason: '', volunteer_id: 0 }); + setShowForm(false); + setEditingId(null); + setConflicts(null); + } + + function canEditOrDelete(r: TimeOffRequest): boolean { + if (role === 'admin') return true; + if (r.volunteer_id !== volunteerID) return false; + return new Date(r.starts_at) > new Date(); + } + const statusClass = (status: string) => { if (status === 'approved') return 'status-approved'; if (status === 'rejected') return 'status-rejected'; return 'status-pending'; }; + const volunteerName = (vid: number) => { + const v = volunteers.find(v => v.id === vid); + return v ? v.name : `#${vid}`; + }; + return (

Time Off Requests

-
{error &&

{error}

} {showForm && ( -
-

New Request

+ +

{editingId ? 'Edit Request' : 'New Request'}

+ {role === 'admin' && !editingId && ( + + )}