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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user