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:
@@ -134,5 +134,15 @@ var statements = []string{
|
|||||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
INDEX idx_instance_id (instance_id),
|
INDEX idx_instance_id (instance_id),
|
||||||
INDEX idx_volunteer_id (volunteer_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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ type Notifier interface {
|
|||||||
CreateNotification(ctx context.Context, volunteerID int64, message string) error
|
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 {
|
type Handler struct {
|
||||||
store Storer
|
store Storer
|
||||||
notifier Notifier
|
notifier Notifier
|
||||||
|
timeOffChecker TimeOffChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storer is the interface the Handler depends on.
|
// Storer is the interface the Handler depends on.
|
||||||
@@ -41,13 +47,13 @@ type Storer interface {
|
|||||||
ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error
|
ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store, notifier Notifier) *Handler {
|
func NewHandler(store *Store, notifier Notifier, timeOffChecker TimeOffChecker) *Handler {
|
||||||
return &Handler{store: store, notifier: notifier}
|
return &Handler{store: store, notifier: notifier, timeOffChecker: timeOffChecker}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||||
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
|
func NewHandlerFromInterfaces(store Storer, notifier Notifier, timeOffChecker TimeOffChecker) *Handler {
|
||||||
return &Handler{store: store, notifier: notifier}
|
return &Handler{store: store, notifier: notifier, timeOffChecker: timeOffChecker}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -263,6 +269,29 @@ func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
inst, added, err := h.store.UpdateInstance(r.Context(), id, in)
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
respond.Error(w, http.StatusNotFound, "shift not found")
|
respond.Error(w, http.StatusNotFound, "shift not found")
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ func do(t *testing.T, router http.Handler, method, path, body, token string) *ht
|
|||||||
func TestListTemplates_ReturnsEmpty(t *testing.T) {
|
func TestListTemplates_ReturnsEmpty(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -219,7 +219,7 @@ func TestListTemplates_ReturnsEmpty(t *testing.T) {
|
|||||||
func TestCreateTemplate_AdminOnly(t *testing.T) {
|
func TestCreateTemplate_AdminOnly(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 2, "volunteer")
|
token := jwtForRole(t, 2, "volunteer")
|
||||||
@@ -235,7 +235,7 @@ func TestCreateTemplate_AdminOnly(t *testing.T) {
|
|||||||
func TestCreateTemplate_Success(t *testing.T) {
|
func TestCreateTemplate_Success(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -256,7 +256,7 @@ func TestCreateTemplate_Success(t *testing.T) {
|
|||||||
func TestCreateTemplate_MissingFields(t *testing.T) {
|
func TestCreateTemplate_MissingFields(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -271,7 +271,7 @@ func TestCreateTemplate_MissingFields(t *testing.T) {
|
|||||||
func TestDeleteTemplate_AdminOnly(t *testing.T) {
|
func TestDeleteTemplate_AdminOnly(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 2, "volunteer")
|
token := jwtForRole(t, 2, "volunteer")
|
||||||
@@ -285,7 +285,7 @@ func TestDeleteTemplate_AdminOnly(t *testing.T) {
|
|||||||
func TestDeleteTemplate_Success(t *testing.T) {
|
func TestDeleteTemplate_Success(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -303,7 +303,7 @@ func TestDeleteTemplate_Success(t *testing.T) {
|
|||||||
func TestGenerateInstances_AdminOnly(t *testing.T) {
|
func TestGenerateInstances_AdminOnly(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 2, "volunteer")
|
token := jwtForRole(t, 2, "volunteer")
|
||||||
@@ -318,7 +318,7 @@ func TestGenerateInstances_AdminOnly(t *testing.T) {
|
|||||||
func TestGenerateInstances_AlreadyExists(t *testing.T) {
|
func TestGenerateInstances_AlreadyExists(t *testing.T) {
|
||||||
store := &fakeStore{genErr: schedule.ErrAlreadyExists}
|
store := &fakeStore{genErr: schedule.ErrAlreadyExists}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -337,7 +337,7 @@ func TestGenerateInstances_Success(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -362,7 +362,7 @@ func TestPublishMonth_SendsNotifications(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -380,7 +380,7 @@ func TestPublishMonth_SendsNotifications(t *testing.T) {
|
|||||||
func TestUnpublishMonth_SendsNotifications(t *testing.T) {
|
func TestUnpublishMonth_SendsNotifications(t *testing.T) {
|
||||||
store := &fakeStore{unpubResult: []int64{10, 20}}
|
store := &fakeStore{unpubResult: []int64{10, 20}}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -398,7 +398,7 @@ func TestUnpublishMonth_SendsNotifications(t *testing.T) {
|
|||||||
func TestUpdateInstance_AdminOnly(t *testing.T) {
|
func TestUpdateInstance_AdminOnly(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 2, "volunteer")
|
token := jwtForRole(t, 2, "volunteer")
|
||||||
@@ -424,7 +424,7 @@ func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) {
|
|||||||
addedVols: []int64{6}, // Bob is newly added
|
addedVols: []int64{6}, // Bob is newly added
|
||||||
}
|
}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 1, "admin")
|
token := jwtForRole(t, 1, "admin")
|
||||||
@@ -446,7 +446,7 @@ func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) {
|
|||||||
func TestConfirmShift_NotAssigned(t *testing.T) {
|
func TestConfirmShift_NotAssigned(t *testing.T) {
|
||||||
store := &fakeStore{confirmErr: schedule.ErrNotFound}
|
store := &fakeStore{confirmErr: schedule.ErrNotFound}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 5, "volunteer")
|
token := jwtForRole(t, 5, "volunteer")
|
||||||
@@ -460,7 +460,7 @@ func TestConfirmShift_NotAssigned(t *testing.T) {
|
|||||||
func TestConfirmShift_Success(t *testing.T) {
|
func TestConfirmShift_Success(t *testing.T) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
notifier := &fakeNotifier{}
|
notifier := &fakeNotifier{}
|
||||||
h := schedule.NewHandlerFromInterfaces(store, notifier)
|
h := schedule.NewHandlerFromInterfaces(store, notifier, nil)
|
||||||
router := newRouter(h)
|
router := newRouter(h)
|
||||||
|
|
||||||
token := jwtForRole(t, 5, "volunteer")
|
token := jwtForRole(t, 5, "volunteer")
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
|||||||
notificationStore := notification.NewStore(db)
|
notificationStore := notification.NewStore(db)
|
||||||
notificationHandler := notification.NewHandler(notificationStore)
|
notificationHandler := notification.NewHandler(notificationStore)
|
||||||
|
|
||||||
scheduleStore := schedule.NewStore(db)
|
|
||||||
scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore)
|
|
||||||
|
|
||||||
timeoffStore := timeoff.NewStore(db)
|
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)
|
checkinStore := checkin.NewStore(db)
|
||||||
checkinHandler := checkin.NewHandler(checkinStore)
|
checkinHandler := checkin.NewHandler(checkinStore)
|
||||||
@@ -81,7 +82,10 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
|||||||
// Time off
|
// Time off
|
||||||
r.Get("/timeoff", timeoffHandler.List)
|
r.Get("/timeoff", timeoffHandler.List)
|
||||||
r.Post("/timeoff", timeoffHandler.Create)
|
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).Put("/timeoff/{id}/review", timeoffHandler.Review)
|
||||||
|
r.With(middleware.RequireAdmin).Get("/timeoff/{id}/shifts", timeoffHandler.RemovedShifts)
|
||||||
|
|
||||||
// Check-in / check-out
|
// Check-in / check-out
|
||||||
r.Post("/checkin", checkinHandler.CheckIn)
|
r.Post("/checkin", checkinHandler.CheckIn)
|
||||||
|
|||||||
@@ -1,22 +1,56 @@
|
|||||||
package timeoff
|
package timeoff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.unsupervised.ca/walkies/internal/respond"
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
// Notifier is the subset of notification.Store the handler needs.
|
||||||
store *Store
|
type Notifier interface {
|
||||||
|
CreateNotification(ctx context.Context, volunteerID int64, message string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store) *Handler {
|
// AdminLister returns admin volunteer IDs so the handler can notify them.
|
||||||
return &Handler{store: store}
|
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
|
// GET /api/v1/timeoff
|
||||||
@@ -38,6 +72,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/timeoff
|
// 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) {
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
var in CreateInput
|
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")
|
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusInternalServerError, "could not create time off request")
|
respond.Error(w, http.StatusInternalServerError, "could not create time off request")
|
||||||
return
|
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)
|
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
|
// PUT /api/v1/timeoff/{id}/review
|
||||||
func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
521
internal/timeoff/handler_test.go
Normal file
521
internal/timeoff/handler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,12 +27,37 @@ type CreateInput struct {
|
|||||||
StartsAt string `json:"starts_at"`
|
StartsAt string `json:"starts_at"`
|
||||||
EndsAt string `json:"ends_at"`
|
EndsAt string `json:"ends_at"`
|
||||||
Reason string `json:"reason"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewInput struct {
|
type ReviewInput struct {
|
||||||
Status string `json:"status"` // "approved" | "rejected"
|
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 {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
@@ -141,3 +166,207 @@ func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string)
|
|||||||
}
|
}
|
||||||
return s.GetByID(ctx, id)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,6 +397,25 @@ func (s *Store) RecordLogin(ctx context.Context, id int64) error {
|
|||||||
return err
|
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) {
|
func generateToken() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ function getToken(): string | null {
|
|||||||
return localStorage.getItem('token');
|
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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
@@ -17,10 +27,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
const data = await res.json();
|
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;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ApiError };
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Setup
|
// Setup
|
||||||
getSetupStatus: () =>
|
getSetupStatus: () =>
|
||||||
@@ -67,9 +79,16 @@ export const api = {
|
|||||||
|
|
||||||
// Time off
|
// Time off
|
||||||
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
||||||
createTimeOff: (data: CreateTimeOffInput) => request<TimeOffRequest>('POST', '/timeoff', data),
|
createTimeOff: (data: CreateTimeOffInput & { volunteer_id?: number; confirm_conflicts?: boolean }) =>
|
||||||
|
request<TimeOffRequest | TimeOffConflictResponse>('POST', '/timeoff', data),
|
||||||
|
updateTimeOff: (id: number, data: { starts_at: string; ends_at: string; reason?: string }) =>
|
||||||
|
request<TimeOffRequest>('PUT', `/timeoff/${id}`, data),
|
||||||
|
deleteTimeOff: (id: number) =>
|
||||||
|
request<{ deleted: boolean; restored_shifts: ConflictingShift[] }>('DELETE', `/timeoff/${id}`),
|
||||||
reviewTimeOff: (id: number, status: 'approved' | 'rejected') =>
|
reviewTimeOff: (id: number, status: 'approved' | 'rejected') =>
|
||||||
request<TimeOffRequest>('PUT', `/timeoff/${id}/review`, { status }),
|
request<TimeOffRequest>('PUT', `/timeoff/${id}/review`, { status }),
|
||||||
|
getRemovedShifts: (id: number) =>
|
||||||
|
request<ConflictingShift[]>('GET', `/timeoff/${id}/shifts`),
|
||||||
|
|
||||||
// Check-in / out
|
// Check-in / out
|
||||||
checkIn: (schedule_id?: number, notes?: string) =>
|
checkIn: (schedule_id?: number, notes?: string) =>
|
||||||
@@ -206,6 +225,19 @@ export interface CreateTimeOffInput {
|
|||||||
reason?: string;
|
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 {
|
export interface CheckIn {
|
||||||
id: number;
|
id: number;
|
||||||
volunteer_id: number;
|
volunteer_id: number;
|
||||||
|
|||||||
235
web/src/pages/TimeOff.test.tsx
Normal file
235
web/src/pages/TimeOff.test.tsx
Normal file
@@ -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(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<TimeOff />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAsAdmin() {
|
||||||
|
localStorage.setItem('token', ADMIN_TOKEN);
|
||||||
|
return render(
|
||||||
|
<AuthProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<TimeOff />
|
||||||
|
</MemoryRouter>
|
||||||
|
</AuthProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,31 +1,96 @@
|
|||||||
import React, { useEffect, useState, FormEvent } from 'react';
|
import React, { useEffect, useState, FormEvent } from 'react';
|
||||||
import { api, TimeOffRequest } from '../api';
|
import { api, ApiError, TimeOffRequest, ConflictingShift, Volunteer } from '../api';
|
||||||
import { useAuth } from '../auth';
|
import { useAuth } from '../auth';
|
||||||
|
|
||||||
export default function TimeOff() {
|
export default function TimeOff() {
|
||||||
const { role } = useAuth();
|
const { role, volunteerID } = useAuth();
|
||||||
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
|
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
|
||||||
|
const [volunteers, setVolunteers] = useState<Volunteer[]>([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showForm, setShowForm] = useState(false);
|
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<number | null>(null);
|
||||||
|
const [conflicts, setConflicts] = useState<ConflictingShift[] | null>(null);
|
||||||
|
const [deletePreview, setDeletePreview] = useState<{ id: number; shifts: ConflictingShift[] } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listTimeOff().then(setRequests).catch(() => setError('Could not load requests.'));
|
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) {
|
async function handleCreate(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const req = await api.createTimeOff(form);
|
const payload: any = { starts_at: form.starts_at, ends_at: form.ends_at, reason: form.reason };
|
||||||
setRequests(prev => [req, ...prev]);
|
if (role === 'admin' && form.volunteer_id > 0) {
|
||||||
setForm({ starts_at: '', ends_at: '', reason: '' });
|
payload.volunteer_id = form.volunteer_id;
|
||||||
setShowForm(false);
|
}
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
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') {
|
async function handleReview(id: number, status: 'approved' | 'rejected') {
|
||||||
try {
|
try {
|
||||||
const req = await api.reviewTimeOff(id, status);
|
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) => {
|
const statusClass = (status: string) => {
|
||||||
if (status === 'approved') return 'status-approved';
|
if (status === 'approved') return 'status-approved';
|
||||||
if (status === 'rejected') return 'status-rejected';
|
if (status === 'rejected') return 'status-rejected';
|
||||||
return 'status-pending';
|
return 'status-pending';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const volunteerName = (vid: number) => {
|
||||||
|
const v = volunteers.find(v => v.id === vid);
|
||||||
|
return v ? v.name : `#${vid}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2>Time Off Requests</h2>
|
<h2>Time Off Requests</h2>
|
||||||
<button onClick={() => setShowForm(v => !v)}>
|
<button onClick={() => { if (showForm) resetForm(); else setShowForm(true); }}>
|
||||||
{showForm ? 'Cancel' : 'Request Time Off'}
|
{showForm ? 'Cancel' : 'Request Time Off'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form className="card" onSubmit={handleCreate}>
|
<form className="card" onSubmit={editingId ? handleUpdate : handleCreate}>
|
||||||
<h3>New Request</h3>
|
<h3>{editingId ? 'Edit Request' : 'New Request'}</h3>
|
||||||
|
{role === 'admin' && !editingId && (
|
||||||
|
<label>
|
||||||
|
Volunteer
|
||||||
|
<select
|
||||||
|
value={form.volunteer_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, volunteer_id: Number(e.target.value) }))}
|
||||||
|
>
|
||||||
|
<option value={0}>Myself</option>
|
||||||
|
{volunteers.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label>
|
<label>
|
||||||
From
|
From
|
||||||
<input type="date" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
<input type="date" value={form.starts_at} onChange={e => { setForm(f => ({ ...f, starts_at: e.target.value })); setConflicts(null); }} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
To
|
To
|
||||||
<input type="date" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
<input type="date" value={form.ends_at} onChange={e => { setForm(f => ({ ...f, ends_at: e.target.value })); setConflicts(null); }} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Reason
|
Reason
|
||||||
<textarea value={form.reason} onChange={e => setForm(f => ({ ...f, reason: e.target.value }))} />
|
<textarea value={form.reason} onChange={e => setForm(f => ({ ...f, reason: e.target.value }))} />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Submit</button>
|
|
||||||
|
{conflicts && (
|
||||||
|
<div className="card" style={{ background: '#fff3cd', border: '1px solid #ffc107', marginBottom: '1rem' }}>
|
||||||
|
<p><strong>Warning:</strong> This time off conflicts with {conflicts.length} assigned shift(s):</p>
|
||||||
|
<ul>
|
||||||
|
{conflicts.map(c => (
|
||||||
|
<li key={c.instance_id}>{c.name} on {c.date} ({c.start_time}–{c.end_time})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p>You will be removed from these shifts. Continue?</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit">
|
||||||
|
{conflicts ? 'Confirm & Submit' : editingId ? 'Save Changes' : 'Submit'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deletePreview && (
|
||||||
|
<div className="card" style={{ background: '#d4edda', border: '1px solid #28a745', marginBottom: '1rem' }}>
|
||||||
|
<h3>Delete Time Off — Shift Restoration Preview</h3>
|
||||||
|
<p>Deleting this time off will restore the volunteer to {deletePreview.shifts.length} shift(s):</p>
|
||||||
|
<ul>
|
||||||
|
{deletePreview.shifts.map(s => (
|
||||||
|
<li key={s.instance_id}>{s.name} on {s.date} ({s.start_time}–{s.end_time})</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button onClick={() => handleDelete(deletePreview.id)}>Confirm Delete & Restore</button>
|
||||||
|
<button onClick={() => setDeletePreview(null)} style={{ marginLeft: '0.5rem' }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{requests.length === 0 ? (
|
{requests.length === 0 ? (
|
||||||
<p>No time off requests.</p>
|
<p>No time off requests.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{role === 'admin' && <th>Volunteer</th>}
|
||||||
<th>From</th>
|
<th>From</th>
|
||||||
<th>To</th>
|
<th>To</th>
|
||||||
<th>Reason</th>
|
<th>Reason</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
{role === 'admin' && <th>Actions</th>}
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{requests.map(r => (
|
{requests.map(r => (
|
||||||
<tr key={r.id}>
|
<tr key={r.id}>
|
||||||
|
{role === 'admin' && <td>{volunteerName(r.volunteer_id)}</td>}
|
||||||
<td>{new Date(r.starts_at).toLocaleDateString()}</td>
|
<td>{new Date(r.starts_at).toLocaleDateString()}</td>
|
||||||
<td>{new Date(r.ends_at).toLocaleDateString()}</td>
|
<td>{new Date(r.ends_at).toLocaleDateString()}</td>
|
||||||
<td>{r.reason ?? '—'}</td>
|
<td>{r.reason ?? '—'}</td>
|
||||||
<td><span className={statusClass(r.status)}>{r.status}</span></td>
|
<td><span className={statusClass(r.status)}>{r.status}</span></td>
|
||||||
{role === 'admin' && (
|
|
||||||
<td>
|
<td>
|
||||||
{r.status === 'pending' && (
|
{role === 'admin' && r.status === 'pending' && (
|
||||||
<>
|
<>
|
||||||
<button className="btn-small" onClick={() => handleReview(r.id, 'approved')}>Approve</button>
|
<button className="btn-small" onClick={() => handleReview(r.id, 'approved')}>Approve</button>
|
||||||
<button className="btn-small btn-danger" onClick={() => handleReview(r.id, 'rejected')}>Reject</button>
|
<button className="btn-small btn-danger" onClick={() => handleReview(r.id, 'rejected')}>Reject</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
{canEditOrDelete(r) && (
|
||||||
|
<>
|
||||||
|
<button className="btn-small" onClick={() => startEdit(r)}>Edit</button>
|
||||||
|
<button className="btn-small btn-danger" onClick={() => handleDeleteClick(r.id)}>Delete</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user