Implement time off management (Issue #3)
All checks were successful
CI / Go tests & lint (push) Successful in 10s
CI / Frontend tests & type-check (push) Successful in 41s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Successful in 46s

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:
2026-04-09 10:03:47 -03:00
parent bb2c2cbc52
commit 6427595c62
11 changed files with 1500 additions and 57 deletions

View File

@@ -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")

View File

@@ -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")