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:
2026-04-09 10:03:47 -03:00
parent 73ad1ed788
commit 07f11fa94e
11 changed files with 1500 additions and 57 deletions

View File

@@ -1,22 +1,56 @@
package timeoff
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
)
type Handler struct {
store *Store
// Notifier is the subset of notification.Store the handler needs.
type Notifier interface {
CreateNotification(ctx context.Context, volunteerID int64, message string) error
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
// AdminLister returns admin volunteer IDs so the handler can notify them.
type AdminLister interface {
ListAdminIDs(ctx context.Context) ([]int64, error)
}
// Storer is the interface the Handler depends on.
type Storer interface {
Create(ctx context.Context, volunteerID int64, in CreateInput) (*Request, error)
GetByID(ctx context.Context, id int64) (*Request, error)
List(ctx context.Context, volunteerID int64) ([]Request, error)
Review(ctx context.Context, id, reviewerID int64, status string) (*Request, error)
Update(ctx context.Context, id int64, in UpdateInput) (*Request, error)
Delete(ctx context.Context, id int64) error
ConflictingShifts(ctx context.Context, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error)
RemoveFromConflictingShifts(ctx context.Context, timeOffID, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error)
RemovedShiftsForTimeOff(ctx context.Context, timeOffID int64) ([]ConflictingShift, error)
RestoreRemovedShifts(ctx context.Context, timeOffID int64) ([]ConflictingShift, error)
}
type Handler struct {
store Storer
notifier Notifier
adminLister AdminLister
}
func NewHandler(store *Store, notifier Notifier, adminLister AdminLister) *Handler {
return &Handler{store: store, notifier: notifier, adminLister: adminLister}
}
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, notifier Notifier, adminLister AdminLister) *Handler {
return &Handler{store: store, notifier: notifier, adminLister: adminLister}
}
// GET /api/v1/timeoff
@@ -38,6 +72,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
}
// POST /api/v1/timeoff
// Creates time off with status "approved". If confirm_conflicts is true and the
// volunteer is assigned to shifts in the date range, they are auto-removed and
// admins are notified (FR-T03).
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
var in CreateInput
@@ -49,14 +86,181 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
return
}
req, err := h.store.Create(r.Context(), claims.VolunteerID, in)
// Determine target volunteer (admin can create for others, FR-T05)
targetVolunteerID := claims.VolunteerID
if in.VolunteerID > 0 && claims.Role == "admin" {
targetVolunteerID = in.VolunteerID
}
// Check for conflicting shifts
conflicts, err := h.store.ConflictingShifts(r.Context(), targetVolunteerID, in.StartsAt, in.EndsAt)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not check conflicts")
return
}
// If there are conflicts and the user hasn't confirmed, return them
if len(conflicts) > 0 && !in.ConfirmConflicts {
respond.JSON(w, http.StatusConflict, map[string]any{
"message": "Time off conflicts with assigned shifts. Confirm to proceed.",
"conflicts": conflicts,
})
return
}
// Create with auto-approved status
req, err := h.store.Create(r.Context(), targetVolunteerID, in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not create time off request")
return
}
// Auto-approve
req, err = h.store.Review(r.Context(), req.ID, claims.VolunteerID, "approved")
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not approve time off request")
return
}
// Remove from conflicting shifts (FR-T03)
if len(conflicts) > 0 {
removed, err := h.store.RemoveFromConflictingShifts(r.Context(), req.ID, targetVolunteerID, in.StartsAt, in.EndsAt)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not remove from conflicting shifts")
return
}
// Notify admins
h.notifyAdmins(r.Context(), targetVolunteerID, removed)
}
respond.JSON(w, http.StatusCreated, req)
}
// PUT /api/v1/timeoff/{id}
// Volunteers can edit their own future time off (FR-T02). Admins can edit any (FR-T05).
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "time off request not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get time off request")
return
}
// Permission: volunteers can only edit their own future time off
if claims.Role != "admin" {
if existing.VolunteerID != claims.VolunteerID {
respond.Error(w, http.StatusForbidden, "cannot edit another volunteer's time off")
return
}
if !existing.StartsAt.After(time.Now()) {
respond.Error(w, http.StatusForbidden, "cannot edit past time off")
return
}
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.StartsAt == "" || in.EndsAt == "" {
respond.Error(w, http.StatusBadRequest, "starts_at and ends_at are required")
return
}
req, err := h.store.Update(r.Context(), id, in)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not update time off request")
return
}
respond.JSON(w, http.StatusOK, req)
}
// DELETE /api/v1/timeoff/{id}
// Volunteers can delete their own future time off (FR-T02).
// Admin delete restores the volunteer to previously removed shifts (FR-T04).
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
existing, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "time off request not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get time off request")
return
}
// Permission: volunteers can only delete their own future time off
if claims.Role != "admin" {
if existing.VolunteerID != claims.VolunteerID {
respond.Error(w, http.StatusForbidden, "cannot delete another volunteer's time off")
return
}
if !existing.StartsAt.After(time.Now()) {
respond.Error(w, http.StatusForbidden, "cannot delete past time off")
return
}
}
// If admin is deleting, restore volunteer to removed shifts (FR-T04)
var restored []ConflictingShift
if claims.Role == "admin" {
restored, err = h.store.RestoreRemovedShifts(r.Context(), id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not restore shifts")
return
}
}
if err := h.store.Delete(r.Context(), id); err != nil {
respond.Error(w, http.StatusInternalServerError, "could not delete time off request")
return
}
respond.JSON(w, http.StatusOK, map[string]any{
"deleted": true,
"restored_shifts": restored,
})
}
// GET /api/v1/timeoff/{id}/shifts
// Returns shifts that were removed due to this time-off request (preview for FR-T04).
func (h *Handler) RemovedShifts(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
shifts, err := h.store.RemovedShiftsForTimeOff(r.Context(), id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get removed shifts")
return
}
if shifts == nil {
shifts = []ConflictingShift{}
}
respond.JSON(w, http.StatusOK, shifts)
}
// PUT /api/v1/timeoff/{id}/review
func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
@@ -85,3 +289,19 @@ func (h *Handler) Review(w http.ResponseWriter, r *http.Request) {
}
respond.JSON(w, http.StatusOK, req)
}
// notifyAdmins sends a notification to all admin users about a volunteer's
// shift removals due to time off (FR-T03).
func (h *Handler) notifyAdmins(ctx context.Context, volunteerID int64, removed []ConflictingShift) {
if len(removed) == 0 {
return
}
adminIDs, err := h.adminLister.ListAdminIDs(ctx)
if err != nil {
return
}
msg := fmt.Sprintf("Volunteer %d has been removed from %d shift(s) due to time off.", volunteerID, len(removed))
for _, aid := range adminIDs {
h.notifier.CreateNotification(ctx, aid, msg) //nolint:errcheck
}
}

View 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)
}
}

View File

@@ -24,6 +24,14 @@ type Request struct {
}
type CreateInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Reason string `json:"reason"`
VolunteerID int64 `json:"volunteer_id,omitempty"` // admin creating for another volunteer
ConfirmConflicts bool `json:"confirm_conflicts,omitempty"` // acknowledge shift conflicts
}
type UpdateInput struct {
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at"`
Reason string `json:"reason"`
@@ -33,6 +41,23 @@ type ReviewInput struct {
Status string `json:"status"` // "approved" | "rejected"
}
// ConflictingShift is a shift instance that overlaps with a time-off period.
type ConflictingShift struct {
InstanceID int64 `json:"instance_id"`
Name string `json:"name"`
Date string `json:"date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
}
// RemovedShift records that a volunteer was removed from a shift due to time off.
type RemovedShift struct {
ID int64 `json:"id"`
TimeOffID int64 `json:"time_off_id"`
InstanceID int64 `json:"instance_id"`
VolunteerID int64 `json:"volunteer_id"`
}
type Store struct {
db *sql.DB
}
@@ -141,3 +166,207 @@ func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string)
}
return s.GetByID(ctx, id)
}
// Update edits a time-off request's dates and reason.
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Request, error) {
_, err := s.db.ExecContext(ctx,
`UPDATE time_off_requests SET starts_at=?, ends_at=?, reason=?, updated_at=NOW() WHERE id=?`,
in.StartsAt, in.EndsAt, in.Reason, id,
)
if err != nil {
return nil, fmt.Errorf("update time off request: %w", err)
}
return s.GetByID(ctx, id)
}
// Delete removes a time-off request.
func (s *Store) Delete(ctx context.Context, id int64) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM time_off_requests WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete time off request: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return ErrNotFound
}
return nil
}
// ConflictingShifts returns published shift instances where the volunteer is assigned
// and the shift date falls within the given date range (inclusive).
func (s *Store) ConflictingShifts(ctx context.Context, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT si.id, si.name, si.date, si.start_time, si.end_time
FROM shift_instances si
JOIN shift_instance_volunteers siv ON siv.instance_id = si.id
WHERE siv.volunteer_id = ?
AND si.date >= ?
AND si.date <= ?
ORDER BY si.date`, volunteerID, startsAt, endsAt,
)
if err != nil {
return nil, fmt.Errorf("query conflicting shifts: %w", err)
}
defer rows.Close()
var shifts []ConflictingShift
for rows.Next() {
var cs ConflictingShift
if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil {
return nil, err
}
shifts = append(shifts, cs)
}
return shifts, rows.Err()
}
// RemoveFromConflictingShifts removes the volunteer from shifts that overlap with
// the time-off period and records the removals for later restoration.
func (s *Store) RemoveFromConflictingShifts(ctx context.Context, timeOffID, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) {
conflicts, err := s.ConflictingShifts(ctx, volunteerID, startsAt, endsAt)
if err != nil {
return nil, err
}
if len(conflicts) == 0 {
return nil, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
for _, c := range conflicts {
// Record the removal
if _, err := tx.ExecContext(ctx,
`INSERT INTO time_off_removed_shifts (time_off_id, instance_id, volunteer_id) VALUES (?, ?, ?)`,
timeOffID, c.InstanceID, volunteerID,
); err != nil {
return nil, fmt.Errorf("record removal: %w", err)
}
// Remove volunteer from shift
if _, err := tx.ExecContext(ctx,
`DELETE FROM shift_instance_volunteers WHERE instance_id = ? AND volunteer_id = ?`,
c.InstanceID, volunteerID,
); err != nil {
return nil, fmt.Errorf("remove from shift: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return conflicts, nil
}
// RemovedShiftsForTimeOff returns shifts from which the volunteer was removed
// due to the given time-off request (for preview before admin deletes time off).
func (s *Store) RemovedShiftsForTimeOff(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT si.id, si.name, si.date, si.start_time, si.end_time
FROM time_off_removed_shifts tors
JOIN shift_instances si ON si.id = tors.instance_id
WHERE tors.time_off_id = ?
ORDER BY si.date`, timeOffID,
)
if err != nil {
return nil, fmt.Errorf("query removed shifts: %w", err)
}
defer rows.Close()
var shifts []ConflictingShift
for rows.Next() {
var cs ConflictingShift
if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil {
return nil, err
}
shifts = append(shifts, cs)
}
return shifts, rows.Err()
}
// RestoreRemovedShifts re-adds the volunteer to shifts they were removed from
// when the given time-off request is deleted (FR-T04).
func (s *Store) RestoreRemovedShifts(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) {
// Get the removals first
rows, err := s.db.QueryContext(ctx,
`SELECT tors.instance_id, tors.volunteer_id, si.name, si.date, si.start_time, si.end_time
FROM time_off_removed_shifts tors
JOIN shift_instances si ON si.id = tors.instance_id
WHERE tors.time_off_id = ?`, timeOffID,
)
if err != nil {
return nil, fmt.Errorf("query removals: %w", err)
}
defer rows.Close()
type removal struct {
instanceID int64
volunteerID int64
shift ConflictingShift
}
var removals []removal
for rows.Next() {
var r removal
if err := rows.Scan(&r.instanceID, &r.volunteerID, &r.shift.Name, &r.shift.Date, &r.shift.StartTime, &r.shift.EndTime); err != nil {
return nil, err
}
r.shift.InstanceID = r.instanceID
removals = append(removals, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(removals) == 0 {
return nil, nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var restored []ConflictingShift
for _, r := range removals {
// Re-add to shift (ignore duplicate if they were re-added manually)
_, err := tx.ExecContext(ctx,
`INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
r.instanceID, r.volunteerID,
)
if err != nil {
return nil, fmt.Errorf("restore to shift: %w", err)
}
restored = append(restored, r.shift)
}
// Clean up removal records (CASCADE will handle this on time-off delete,
// but we clean up explicitly since we're restoring)
if _, err := tx.ExecContext(ctx,
`DELETE FROM time_off_removed_shifts WHERE time_off_id = ?`, timeOffID,
); err != nil {
return nil, fmt.Errorf("clean removals: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return restored, nil
}
// HasApprovedTimeOff checks if a volunteer has approved time off covering the given date.
func (s *Store) HasApprovedTimeOff(ctx context.Context, volunteerID int64, date string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM time_off_requests
WHERE volunteer_id = ? AND status = 'approved'
AND ? >= DATE(starts_at) AND ? <= DATE(ends_at)`,
volunteerID, date, date,
).Scan(&count)
if err != nil {
return false, fmt.Errorf("check time off: %w", err)
}
return count > 0, nil
}