diff --git a/internal/db/schema.go b/internal/db/schema.go index 8af5dff..6c228a1 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -77,5 +77,62 @@ var statements = []string{ FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, INDEX idx_volunteer_id (volunteer_id), INDEX idx_read (read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + day_of_week TINYINT NOT NULL COMMENT '0=Sunday 1=Monday ... 6=Saturday (matches Go time.Weekday)', + start_time TIME NOT NULL, + end_time TIME NOT NULL, + min_capacity INT NOT NULL DEFAULT 1, + max_capacity INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_template_roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + role_name VARCHAR(255) NOT NULL, + count INT NOT NULL DEFAULT 1, + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE, + INDEX idx_template_id (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_template_volunteers ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + volunteer_id INT NOT NULL, + UNIQUE KEY uq_template_volunteer (template_id, volunteer_id), + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE CASCADE, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_instances ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NULL, + name VARCHAR(255) NOT NULL, + date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + min_capacity INT NOT NULL DEFAULT 1, + max_capacity INT NOT NULL DEFAULT 1, + status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft or published', + year INT NOT NULL, + month INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (template_id) REFERENCES shift_templates(id) ON DELETE SET NULL, + INDEX idx_year_month (year, month), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS shift_instance_volunteers ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NOT NULL, + volunteer_id INT NOT NULL, + confirmed TINYINT NOT NULL DEFAULT 0, + confirmed_at DATETIME NULL, + UNIQUE KEY uq_instance_volunteer (instance_id, volunteer_id), + FOREIGN KEY (instance_id) REFERENCES shift_instances(id) ON DELETE CASCADE, + FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE, + INDEX idx_instance_id (instance_id), + INDEX idx_volunteer_id (volunteer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, } diff --git a/internal/notification/notification.go b/internal/notification/notification.go index d805583..bb7e8db 100644 --- a/internal/notification/notification.go +++ b/internal/notification/notification.go @@ -77,6 +77,12 @@ func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Noti return notifications, rows.Err() } +// CreateNotification satisfies the schedule.Notifier interface. +func (s *Store) CreateNotification(ctx context.Context, volunteerID int64, message string) error { + _, err := s.Create(ctx, volunteerID, message) + return err +} + func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) { result, err := s.db.ExecContext(ctx, `UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`, diff --git a/internal/schedule/handler.go b/internal/schedule/handler.go index 0a0272d..40b2757 100644 --- a/internal/schedule/handler.go +++ b/internal/schedule/handler.go @@ -1,99 +1,329 @@ package schedule 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" ) +// Notifier is the subset of notification.Store the handler needs. +type Notifier interface { + CreateNotification(ctx context.Context, volunteerID int64, message string) error +} + type Handler struct { - store *Store + store Storer + notifier Notifier } -func NewHandler(store *Store) *Handler { - return &Handler{store: store} +// Storer is the interface the Handler depends on. +type Storer interface { + CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) + GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) + ListTemplates(ctx context.Context) ([]ShiftTemplate, error) + UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) + DeleteTemplate(ctx context.Context, id int64) error + + GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) + ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) + GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) + UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (*ShiftInstance, []int64, error) + PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) + UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) + ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error } -// GET /api/v1/schedules -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - volunteerID := int64(0) - if claims.Role != "admin" { - volunteerID = claims.VolunteerID - } - schedules, err := h.store.List(r.Context(), volunteerID) +func NewHandler(store *Store, notifier Notifier) *Handler { + return &Handler{store: store, notifier: notifier} +} + +// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing. +func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler { + return &Handler{store: store, notifier: notifier} +} + +// --------------------------------------------------------------------------- +// Template handlers +// --------------------------------------------------------------------------- + +// GET /api/v1/shift-templates +func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := h.store.ListTemplates(r.Context()) if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not list schedules") + respond.Error(w, http.StatusInternalServerError, "could not list templates") return } - if schedules == nil { - schedules = []Schedule{} + if templates == nil { + templates = []ShiftTemplate{} } - respond.JSON(w, http.StatusOK, schedules) + respond.JSON(w, http.StatusOK, templates) } -// POST /api/v1/schedules -func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { - claims := middleware.ClaimsFromContext(r.Context()) - var in CreateInput +// POST /api/v1/shift-templates +func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) { + var in CreateTemplateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } - if claims.Role != "admin" { - in.VolunteerID = claims.VolunteerID - } - if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" { - respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required") + if in.Name == "" || in.StartTime == "" || in.EndTime == "" { + respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required") return } - sc, err := h.store.Create(r.Context(), in) + if in.MinCapacity <= 0 { + in.MinCapacity = 1 + } + if in.MaxCapacity < in.MinCapacity { + in.MaxCapacity = in.MinCapacity + } + t, err := h.store.CreateTemplate(r.Context(), in) if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not create schedule") + respond.Error(w, http.StatusInternalServerError, "could not create template") return } - respond.JSON(w, http.StatusCreated, sc) + respond.JSON(w, http.StatusCreated, t) } -// PUT /api/v1/schedules/{id} -func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { +// PUT /api/v1/shift-templates/{id} +func (h *Handler) UpdateTemplate(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 } - var in UpdateInput + var in UpdateTemplateInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } - sc, err := h.store.Update(r.Context(), id, in) + t, err := h.store.UpdateTemplate(r.Context(), id, in) if errors.Is(err, ErrNotFound) { - respond.Error(w, http.StatusNotFound, "schedule not found") + respond.Error(w, http.StatusNotFound, "template not found") return } if err != nil { - respond.Error(w, http.StatusInternalServerError, "could not update schedule") + respond.Error(w, http.StatusInternalServerError, "could not update template") return } - respond.JSON(w, http.StatusOK, sc) + respond.JSON(w, http.StatusOK, t) } -// DELETE /api/v1/schedules/{id} -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { +// DELETE /api/v1/shift-templates/{id} +func (h *Handler) DeleteTemplate(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 } - if err := h.store.Delete(r.Context(), id); err != nil { - respond.Error(w, http.StatusInternalServerError, "could not delete schedule") + if err := h.store.DeleteTemplate(r.Context(), id); err != nil { + respond.Error(w, http.StatusInternalServerError, "could not delete template") return } w.WriteHeader(http.StatusNoContent) } + +// --------------------------------------------------------------------------- +// Instance handlers +// --------------------------------------------------------------------------- + +// GET /api/v1/shifts?year=2026&month=4 +func (h *Handler) ListInstances(w http.ResponseWriter, r *http.Request) { + year, month := parseYearMonth(r) + claims := middleware.ClaimsFromContext(r.Context()) + + volunteerID := int64(0) + if claims.Role != "admin" { + volunteerID = claims.VolunteerID + } + + instances, err := h.store.ListInstances(r.Context(), year, month, volunteerID) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not list shifts") + return + } + if instances == nil { + instances = []ShiftInstance{} + } + respond.JSON(w, http.StatusOK, instances) +} + +// POST /api/v1/shifts/generate body: {"year":2026,"month":4} +func (h *Handler) GenerateInstances(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + instances, err := h.store.GenerateInstances(r.Context(), body.Year, body.Month) + if errors.Is(err, ErrAlreadyExists) { + respond.Error(w, http.StatusConflict, "shifts already generated for this period") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not generate shifts") + return + } + respond.JSON(w, http.StatusCreated, instances) +} + +// POST /api/v1/shifts/publish body: {"year":2026,"month":4} +func (h *Handler) PublishMonth(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + + byVol, err := h.store.PublishMonth(r.Context(), body.Year, body.Month) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not publish schedule") + return + } + + // Notify each affected volunteer (FR-S04) + mn := time.Month(body.Month).String() + for vid, shifts := range byVol { + msg := fmt.Sprintf("Your schedule for %s %d has been published. You have %d shift(s).", + mn, body.Year, len(shifts)) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + + respond.JSON(w, http.StatusOK, map[string]any{ + "year": body.Year, + "month": body.Month, + }) +} + +// POST /api/v1/shifts/unpublish body: {"year":2026,"month":4} +func (h *Handler) UnpublishMonth(w http.ResponseWriter, r *http.Request) { + var body struct { + Year int `json:"year"` + Month int `json:"month"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + if body.Year == 0 || body.Month < 1 || body.Month > 12 { + respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required") + return + } + + volunteerIDs, err := h.store.UnpublishMonth(r.Context(), body.Year, body.Month) + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not unpublish schedule") + return + } + + // Notify affected volunteers (FR-S05) + mn := time.Month(body.Month).String() + for _, vid := range volunteerIDs { + msg := fmt.Sprintf("The schedule for %s %d has been retracted.", mn, body.Year) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + + respond.JSON(w, http.StatusOK, map[string]any{ + "year": body.Year, + "month": body.Month, + }) +} + +// PUT /api/v1/shifts/{id} +func (h *Handler) UpdateInstance(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 + } + var in UpdateInstanceInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid request body") + return + } + + inst, added, err := h.store.UpdateInstance(r.Context(), id, in) + if errors.Is(err, ErrNotFound) { + respond.Error(w, http.StatusNotFound, "shift not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "could not update shift") + return + } + + // Notify all volunteers on a published shift of the change (FR-S09) + if inst.Status == "published" && in.VolunteerIDs != nil { + for _, v := range inst.Volunteers { + msg := fmt.Sprintf("Your shift on %s (%s–%s) has been updated. Please re-confirm.", inst.Date, inst.StartTime, inst.EndTime) + h.notifier.CreateNotification(r.Context(), v.VolunteerID, msg) //nolint:errcheck + } + } + + // Notify only newly added volunteers (FR-S10) + if inst.Status == "published" && len(added) > 0 { + for _, vid := range added { + msg := fmt.Sprintf("You have been added to a shift on %s (%s–%s).", inst.Date, inst.StartTime, inst.EndTime) + h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck + } + } + + respond.JSON(w, http.StatusOK, inst) +} + +// POST /api/v1/shifts/{id}/confirm +func (h *Handler) ConfirmShift(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 + } + claims := middleware.ClaimsFromContext(r.Context()) + if err := h.store.ConfirmShift(r.Context(), id, claims.VolunteerID); err != nil { + if errors.Is(err, ErrNotFound) { + respond.Error(w, http.StatusNotFound, "shift assignment not found") + return + } + respond.Error(w, http.StatusInternalServerError, "could not confirm shift") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func parseYearMonth(r *http.Request) (year, month int) { + now := time.Now() + year = now.Year() + month = int(now.Month()) + if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil { + year = y + } + if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 { + month = m + } + return year, month +} diff --git a/internal/schedule/handler_test.go b/internal/schedule/handler_test.go new file mode 100644 index 0000000..dd7674b --- /dev/null +++ b/internal/schedule/handler_test.go @@ -0,0 +1,476 @@ +package schedule_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.unsupervised.ca/walkies/internal/auth" + "git.unsupervised.ca/walkies/internal/schedule" + "git.unsupervised.ca/walkies/internal/server/middleware" + "github.com/go-chi/chi/v5" +) + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +type fakeStore struct { + templates []schedule.ShiftTemplate + instances []schedule.ShiftInstance + createTmpl *schedule.ShiftTemplate + createErr error + updateTmpl *schedule.ShiftTemplate + updateErr error + deleteErr error + genResult []schedule.ShiftInstance + genErr error + publishResult map[int64][]schedule.ShiftInstance + publishErr error + unpubResult []int64 + unpubErr error + updateInst *schedule.ShiftInstance + addedVols []int64 + updateInstErr error + confirmErr error +} + +func (f *fakeStore) CreateTemplate(_ context.Context, in schedule.CreateTemplateInput) (*schedule.ShiftTemplate, error) { + if f.createErr != nil { + return nil, f.createErr + } + if f.createTmpl != nil { + return f.createTmpl, nil + } + return &schedule.ShiftTemplate{ID: 1, Name: in.Name, DayOfWeek: in.DayOfWeek, + StartTime: in.StartTime, EndTime: in.EndTime, + MinCapacity: in.MinCapacity, MaxCapacity: in.MaxCapacity, + Roles: []schedule.TemplateRole{}, VolunteerIDs: []int64{}}, nil +} + +func (f *fakeStore) GetTemplate(_ context.Context, id int64) (*schedule.ShiftTemplate, error) { + for _, t := range f.templates { + if t.ID == id { + return &t, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) ListTemplates(_ context.Context) ([]schedule.ShiftTemplate, error) { + return f.templates, nil +} + +func (f *fakeStore) UpdateTemplate(_ context.Context, id int64, _ schedule.UpdateTemplateInput) (*schedule.ShiftTemplate, error) { + if f.updateErr != nil { + return nil, f.updateErr + } + if f.updateTmpl != nil { + return f.updateTmpl, nil + } + for _, t := range f.templates { + if t.ID == id { + return &t, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) DeleteTemplate(_ context.Context, _ int64) error { + return f.deleteErr +} + +func (f *fakeStore) GenerateInstances(_ context.Context, _, _ int) ([]schedule.ShiftInstance, error) { + return f.genResult, f.genErr +} + +func (f *fakeStore) ListInstances(_ context.Context, _, _ int, _ int64) ([]schedule.ShiftInstance, error) { + return f.instances, nil +} + +func (f *fakeStore) GetInstance(_ context.Context, id int64) (*schedule.ShiftInstance, error) { + for _, inst := range f.instances { + if inst.ID == id { + return &inst, nil + } + } + return nil, schedule.ErrNotFound +} + +func (f *fakeStore) UpdateInstance(_ context.Context, _ int64, _ schedule.UpdateInstanceInput) (*schedule.ShiftInstance, []int64, error) { + return f.updateInst, f.addedVols, f.updateInstErr +} + +func (f *fakeStore) PublishMonth(_ context.Context, _, _ int) (map[int64][]schedule.ShiftInstance, error) { + return f.publishResult, f.publishErr +} + +func (f *fakeStore) UnpublishMonth(_ context.Context, _, _ int) ([]int64, error) { + return f.unpubResult, f.unpubErr +} + +func (f *fakeStore) ConfirmShift(_ context.Context, _, _ int64) error { + return f.confirmErr +} + +type fakeNotifier struct { + calls []struct { + volunteerID int64 + message string + } +} + +func (n *fakeNotifier) CreateNotification(_ context.Context, volunteerID int64, message string) error { + n.calls = append(n.calls, struct { + volunteerID int64 + message string + }{volunteerID, message}) + return 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 *schedule.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/shift-templates", h.ListTemplates) + r.Post("/api/v1/shift-templates", + middleware.RequireAdmin(http.HandlerFunc(h.CreateTemplate)).ServeHTTP) + r.Put("/api/v1/shift-templates/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.UpdateTemplate)).ServeHTTP) + r.Delete("/api/v1/shift-templates/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.DeleteTemplate)).ServeHTTP) + + r.Get("/api/v1/shifts", h.ListInstances) + r.Post("/api/v1/shifts/generate", + middleware.RequireAdmin(http.HandlerFunc(h.GenerateInstances)).ServeHTTP) + r.Post("/api/v1/shifts/publish", + middleware.RequireAdmin(http.HandlerFunc(h.PublishMonth)).ServeHTTP) + r.Post("/api/v1/shifts/unpublish", + middleware.RequireAdmin(http.HandlerFunc(h.UnpublishMonth)).ServeHTTP) + r.Put("/api/v1/shifts/{id}", + middleware.RequireAdmin(http.HandlerFunc(h.UpdateInstance)).ServeHTTP) + r.Post("/api/v1/shifts/{id}/confirm", h.ConfirmShift) + }) + 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 +} + +// --------------------------------------------------------------------------- +// Template tests +// --------------------------------------------------------------------------- + +func TestListTemplates_ReturnsEmpty(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "GET", "/api/v1/shift-templates", "", token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + var result []schedule.ShiftTemplate + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("expected empty list, got %d items", len(result)) + } +} + +func TestCreateTemplate_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, + token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestCreateTemplate_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"name":"Morning","day_of_week":1,"start_time":"09:00:00","end_time":"12:00:00","min_capacity":2,"max_capacity":5}`, + token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var tmpl schedule.ShiftTemplate + json.NewDecoder(w.Body).Decode(&tmpl) + if tmpl.Name != "Morning" { + t.Errorf("expected name Morning, got %q", tmpl.Name) + } +} + +func TestCreateTemplate_MissingFields(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shift-templates", + `{"day_of_week":1}`, token) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestDeleteTemplate_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestDeleteTemplate_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "DELETE", "/api/v1/shift-templates/1", "", token) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +// --------------------------------------------------------------------------- +// Instance tests +// --------------------------------------------------------------------------- + +func TestGenerateInstances_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestGenerateInstances_AlreadyExists(t *testing.T) { + store := &fakeStore{genErr: schedule.ErrAlreadyExists} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", w.Code, w.Body) + } +} + +func TestGenerateInstances_Success(t *testing.T) { + store := &fakeStore{ + genResult: []schedule.ShiftInstance{ + {ID: 1, Name: "Morning", Date: "2026-04-06", Status: "draft", Volunteers: []schedule.InstanceVolunteer{}}, + }, + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/generate", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body) + } + var result []schedule.ShiftInstance + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 1 { + t.Errorf("expected 1 instance, got %d", len(result)) + } +} + +func TestPublishMonth_SendsNotifications(t *testing.T) { + store := &fakeStore{ + publishResult: map[int64][]schedule.ShiftInstance{ + 10: {{ID: 1, Name: "Morning", Date: "2026-04-06", Volunteers: []schedule.InstanceVolunteer{}}}, + 20: {{ID: 2, Name: "Morning", Date: "2026-04-13", Volunteers: []schedule.InstanceVolunteer{}}}, + }, + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/publish", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if len(notifier.calls) != 2 { + t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) + } +} + +func TestUnpublishMonth_SendsNotifications(t *testing.T) { + store := &fakeStore{unpubResult: []int64{10, 20}} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + w := do(t, router, "POST", "/api/v1/shifts/unpublish", + `{"year":2026,"month":4}`, token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + if len(notifier.calls) != 2 { + t.Errorf("expected 2 notifications, got %d", len(notifier.calls)) + } +} + +func TestUpdateInstance_AdminOnly(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 2, "volunteer") + w := do(t, router, "PUT", "/api/v1/shifts/1", + `{"volunteer_ids":[5]}`, token) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", w.Code) + } +} + +func TestUpdateInstance_PublishedResetsAndNotifies(t *testing.T) { + vids := []int64{5, 6} + store := &fakeStore{ + updateInst: &schedule.ShiftInstance{ + ID: 1, Status: "published", Date: "2026-04-06", + StartTime: "09:00:00", EndTime: "12:00:00", + Volunteers: []schedule.InstanceVolunteer{ + {InstanceID: 1, VolunteerID: 5, Name: "Alice"}, + {InstanceID: 1, VolunteerID: 6, Name: "Bob"}, + }, + }, + addedVols: []int64{6}, // Bob is newly added + } + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 1, "admin") + body, _ := json.Marshal(map[string]any{"volunteer_ids": vids}) + w := do(t, router, "PUT", "/api/v1/shifts/1", string(body), token) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body) + } + // 2 notifications for existing volunteers (reset) + 1 for newly added + // Total = 3 but FR-S10 is a subset of FR-S09, so we don't double-count + // The handler sends update notices to all current volunteers (2) and + // an "added" notice only to the newly added one (1) = 3 notifications. + if len(notifier.calls) != 3 { + t.Errorf("expected 3 notifications (2 reset + 1 added), got %d", len(notifier.calls)) + } +} + +func TestConfirmShift_NotAssigned(t *testing.T) { + store := &fakeStore{confirmErr: schedule.ErrNotFound} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 5, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/99/confirm", "", token) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestConfirmShift_Success(t *testing.T) { + store := &fakeStore{} + notifier := &fakeNotifier{} + h := schedule.NewHandlerFromInterfaces(store, notifier) + router := newRouter(h) + + token := jwtForRole(t, 5, "volunteer") + w := do(t, router, "POST", "/api/v1/shifts/1/confirm", "", token) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } +} + +// Compile-time interface check +var _ schedule.Storer = (*fakeStore)(nil) +var _ schedule.Notifier = (*fakeNotifier)(nil) diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index 4cba542..eba2e0b 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -8,35 +8,96 @@ import ( "time" ) -var ErrNotFound = fmt.Errorf("schedule not found") +var ( + ErrNotFound = fmt.Errorf("not found") + ErrAlreadyExists = fmt.Errorf("instances already generated for this period") +) -type Schedule struct { - ID int64 `json:"id"` - VolunteerID int64 `json:"volunteer_id"` - Title string `json:"title"` - StartsAt time.Time `json:"starts_at"` - EndsAt time.Time `json:"ends_at"` - Notes string `json:"notes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +type ShiftTemplate struct { + ID int64 `json:"id"` + Name string `json:"name"` + DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat + StartTime string `json:"start_time"` // "HH:MM:SS" + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -type CreateInput struct { - VolunteerID int64 `json:"volunteer_id"` - Title string `json:"title"` - StartsAt string `json:"starts_at"` - EndsAt string `json:"ends_at"` - Notes string `json:"notes"` +type TemplateRole struct { + ID int64 `json:"id"` + TemplateID int64 `json:"template_id"` + RoleName string `json:"role_name"` + Count int `json:"count"` } -type UpdateInput struct { - Title *string `json:"title"` - StartsAt *string `json:"starts_at"` - EndsAt *string `json:"ends_at"` - Notes *string `json:"notes"` +type ShiftInstance struct { + ID int64 `json:"id"` + TemplateID *int64 `json:"template_id,omitempty"` + Name string `json:"name"` + Date string `json:"date"` // "YYYY-MM-DD" + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Status string `json:"status"` // "draft" or "published" + Year int `json:"year"` + Month int `json:"month"` + Volunteers []InstanceVolunteer `json:"volunteers"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -const timeLayout = "2006-01-02T15:04:05Z" +type InstanceVolunteer struct { + InstanceID int64 `json:"instance_id"` + VolunteerID int64 `json:"volunteer_id"` + Name string `json:"name"` + Confirmed bool `json:"confirmed"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` +} + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +type CreateTemplateInput struct { + Name string `json:"name"` + DayOfWeek int `json:"day_of_week"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + MinCapacity int `json:"min_capacity"` + MaxCapacity int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` +} + +type UpdateTemplateInput struct { + Name *string `json:"name"` + DayOfWeek *int `json:"day_of_week"` + StartTime *string `json:"start_time"` + EndTime *string `json:"end_time"` + MinCapacity *int `json:"min_capacity"` + MaxCapacity *int `json:"max_capacity"` + Roles []TemplateRole `json:"roles"` + VolunteerIDs []int64 `json:"volunteer_ids"` +} + +type UpdateInstanceInput struct { + VolunteerIDs *[]int64 `json:"volunteer_ids"` + MinCapacity *int `json:"min_capacity"` + MaxCapacity *int `json:"max_capacity"` +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- type Store struct { db *sql.DB @@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store { return &Store{db: db} } -func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) { - res, err := s.db.ExecContext(ctx, - `INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`, - in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes, +// --------------------------------------------------------------------------- +// Template operations +// --------------------------------------------------------------------------- + +func (s *Store) CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + res, err := tx.ExecContext(ctx, + `INSERT INTO shift_templates (name, day_of_week, start_time, end_time, min_capacity, max_capacity) + VALUES (?, ?, ?, ?, ?, ?)`, + in.Name, in.DayOfWeek, in.StartTime, in.EndTime, in.MinCapacity, in.MaxCapacity, ) if err != nil { - return nil, fmt.Errorf("insert schedule: %w", err) + return nil, fmt.Errorf("insert template: %w", err) } id, _ := res.LastInsertId() - return s.GetByID(ctx, id) + + if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil { + return nil, err + } + if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetTemplate(ctx, id) } -func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) { - sc := &Schedule{} - var startsAt, endsAt, createdAt, updatedAt string - var notes sql.NullString +func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) { + t := &ShiftTemplate{} + var createdAt, updatedAt string err := s.db.QueryRowContext(ctx, - `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id, - ).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt) + `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at + FROM shift_templates WHERE id = ?`, id, + ).Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime, + &t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { - return nil, fmt.Errorf("get schedule: %w", err) + return nil, fmt.Errorf("get template: %w", err) } - sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) - sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) - sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - if notes.Valid { - sc.Notes = notes.String - } - return sc, nil -} + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) -func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) { - query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules` - args := []any{} - if volunteerID > 0 { - query += ` WHERE volunteer_id = ?` - args = append(args, volunteerID) - } - query += ` ORDER BY starts_at` - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("list schedules: %w", err) - } - defer rows.Close() - - var schedules []Schedule - for rows.Next() { - var sc Schedule - var startsAt, endsAt, createdAt, updatedAt string - var notes sql.NullString - if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil { - return nil, err - } - sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) - sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) - sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - if notes.Valid { - sc.Notes = notes.String - } - schedules = append(schedules, sc) - } - return schedules, rows.Err() -} - -func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) { - sc, err := s.GetByID(ctx, id) + roles, err := s.templateRoles(ctx, id) if err != nil { return nil, err } - title := sc.Title - startsAt := sc.StartsAt.Format("2006-01-02 15:04:05") - endsAt := sc.EndsAt.Format("2006-01-02 15:04:05") - notes := sc.Notes + t.Roles = roles - if in.Title != nil { - title = *in.Title + vids, err := s.templateVolunteerIDs(ctx, id) + if err != nil { + return nil, err } - if in.StartsAt != nil { - startsAt = *in.StartsAt + t.VolunteerIDs = vids + return t, nil +} + +func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at + FROM shift_templates ORDER BY day_of_week, start_time`) + if err != nil { + return nil, fmt.Errorf("list templates: %w", err) } - if in.EndsAt != nil { - endsAt = *in.EndsAt + defer rows.Close() + + var templates []ShiftTemplate + for rows.Next() { + var t ShiftTemplate + var createdAt, updatedAt string + if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime, + &t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + templates = append(templates, t) } - if in.Notes != nil { - notes = *in.Notes + if err := rows.Err(); err != nil { + return nil, err } - _, err = s.db.ExecContext(ctx, - `UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`, - title, startsAt, endsAt, notes, id, + + // Load roles and volunteers for each template + for i := range templates { + roles, err := s.templateRoles(ctx, templates[i].ID) + if err != nil { + return nil, err + } + templates[i].Roles = roles + + vids, err := s.templateVolunteerIDs(ctx, templates[i].ID) + if err != nil { + return nil, err + } + templates[i].VolunteerIDs = vids + } + return templates, nil +} + +func (s *Store) UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) { + t, err := s.GetTemplate(ctx, id) + if err != nil { + return nil, err + } + + name := t.Name + dow := t.DayOfWeek + startTime := t.StartTime + endTime := t.EndTime + minCap := t.MinCapacity + maxCap := t.MaxCapacity + + if in.Name != nil { + name = *in.Name + } + if in.DayOfWeek != nil { + dow = *in.DayOfWeek + } + if in.StartTime != nil { + startTime = *in.StartTime + } + if in.EndTime != nil { + endTime = *in.EndTime + } + if in.MinCapacity != nil { + minCap = *in.MinCapacity + } + if in.MaxCapacity != nil { + maxCap = *in.MaxCapacity + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, + `UPDATE shift_templates SET name=?, day_of_week=?, start_time=?, end_time=?, + min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`, + name, dow, startTime, endTime, minCap, maxCap, id, ) if err != nil { - return nil, fmt.Errorf("update schedule: %w", err) + return nil, fmt.Errorf("update template: %w", err) } - return s.GetByID(ctx, id) + + if in.Roles != nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_roles WHERE template_id = ?`, id); err != nil { + return nil, fmt.Errorf("clear roles: %w", err) + } + if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil { + return nil, err + } + } + if in.VolunteerIDs != nil { + if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_volunteers WHERE template_id = ?`, id); err != nil { + return nil, fmt.Errorf("clear volunteers: %w", err) + } + if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil { + return nil, err + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + return s.GetTemplate(ctx, id) } -func (s *Store) Delete(ctx context.Context, id int64) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id) +func (s *Store) DeleteTemplate(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id) return err } + +// --------------------------------------------------------------------------- +// Instance operations +// --------------------------------------------------------------------------- + +// GenerateInstances creates draft shift instances for every template × date in +// the given month. Returns ErrAlreadyExists if instances already exist for +// that month (FR-S02). +func (s *Store) GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) { + var count int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM shift_instances WHERE year = ? AND month = ?`, year, month, + ).Scan(&count); err != nil { + return nil, fmt.Errorf("check existing: %w", err) + } + if count > 0 { + return nil, ErrAlreadyExists + } + + templates, err := s.ListTemplates(ctx) + if err != nil { + return nil, err + } + + // Find all dates in the month for each template's day of week + first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + daysInMonth := daysIn(year, month) + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var instanceIDs []int64 + for _, tmpl := range templates { + for d := 0; d < daysInMonth; d++ { + day := first.AddDate(0, 0, d) + if int(day.Weekday()) != tmpl.DayOfWeek { + continue + } + res, err := tx.ExecContext(ctx, + `INSERT INTO shift_instances + (template_id, name, date, start_time, end_time, min_capacity, max_capacity, status, year, month) + VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, + tmpl.ID, tmpl.Name, day.Format("2006-01-02"), + tmpl.StartTime, tmpl.EndTime, + tmpl.MinCapacity, tmpl.MaxCapacity, + year, month, + ) + if err != nil { + return nil, fmt.Errorf("insert instance: %w", err) + } + instID, _ := res.LastInsertId() + instanceIDs = append(instanceIDs, instID) + + // Copy recurring volunteer assignments from template + for _, vid := range tmpl.VolunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`, + instID, vid, + ); err != nil { + return nil, fmt.Errorf("copy volunteer: %w", err) + } + } + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return s.ListInstances(ctx, year, month, 0) +} + +// ListInstances returns instances for a month. When volunteerID > 0, only +// returns published instances where that volunteer is assigned. +func (s *Store) ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) { + query := `SELECT id, template_id, name, date, start_time, end_time, + min_capacity, max_capacity, status, year, month, created_at, updated_at + FROM shift_instances WHERE year = ? AND month = ?` + args := []any{year, month} + + if volunteerID > 0 { + query += ` AND status = 'published' + AND id IN (SELECT instance_id FROM shift_instance_volunteers WHERE volunteer_id = ?)` + args = append(args, volunteerID) + } + query += ` ORDER BY date, start_time` + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list instances: %w", err) + } + defer rows.Close() + + var instances []ShiftInstance + for rows.Next() { + inst, err := scanInstance(rows) + if err != nil { + return nil, err + } + instances = append(instances, *inst) + } + if err := rows.Err(); err != nil { + return nil, err + } + + for i := range instances { + vols, err := s.instanceVolunteers(ctx, instances[i].ID) + if err != nil { + return nil, err + } + instances[i].Volunteers = vols + } + return instances, nil +} + +func (s *Store) GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, template_id, name, date, start_time, end_time, + min_capacity, max_capacity, status, year, month, created_at, updated_at + FROM shift_instances WHERE id = ?`, id) + inst, err := scanInstanceRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get instance: %w", err) + } + vols, err := s.instanceVolunteers(ctx, inst.ID) + if err != nil { + return nil, err + } + inst.Volunteers = vols + return inst, nil +} + +// UpdateInstance edits volunteer assignments and/or capacity on any instance. +// For published instances, volunteer confirmation statuses are reset (FR-S09). +// Returns the previous and new volunteer ID sets so the caller can send notifications. +func (s *Store) UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (inst *ShiftInstance, added []int64, err error) { + inst, err = s.GetInstance(ctx, id) + if err != nil { + return nil, nil, err + } + + tx, txErr := s.db.BeginTx(ctx, nil) + if txErr != nil { + return nil, nil, fmt.Errorf("begin tx: %w", txErr) + } + defer tx.Rollback() + + if in.MinCapacity != nil || in.MaxCapacity != nil { + minCap := inst.MinCapacity + maxCap := inst.MaxCapacity + if in.MinCapacity != nil { + minCap = *in.MinCapacity + } + if in.MaxCapacity != nil { + maxCap = *in.MaxCapacity + } + if _, err := tx.ExecContext(ctx, + `UPDATE shift_instances SET min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`, + minCap, maxCap, id, + ); err != nil { + return nil, nil, fmt.Errorf("update capacity: %w", err) + } + } + + if in.VolunteerIDs != nil { + // Determine newly added volunteers (FR-S10) + existing := make(map[int64]bool) + for _, v := range inst.Volunteers { + existing[v.VolunteerID] = true + } + for _, vid := range *in.VolunteerIDs { + if !existing[vid] { + added = append(added, vid) + } + } + + // Replace assignments + if _, err := tx.ExecContext(ctx, + `DELETE FROM shift_instance_volunteers WHERE instance_id = ?`, id, + ); err != nil { + return nil, nil, fmt.Errorf("clear volunteers: %w", err) + } + for _, vid := range *in.VolunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`, + id, vid, + ); err != nil { + return nil, nil, fmt.Errorf("insert volunteer: %w", err) + } + } + + // Reset confirmation for published shifts (FR-S09) + if inst.Status == "published" { + if _, err := tx.ExecContext(ctx, + `UPDATE shift_instance_volunteers SET confirmed=0, confirmed_at=NULL WHERE instance_id=?`, id, + ); err != nil { + return nil, nil, fmt.Errorf("reset confirmations: %w", err) + } + } + } + + if err := tx.Commit(); err != nil { + return nil, nil, fmt.Errorf("commit: %w", err) + } + + inst, err = s.GetInstance(ctx, id) + return inst, added, err +} + +// PublishMonth marks all draft instances for the month as published and returns +// a map of volunteerID → []ShiftInstance for notification purposes (FR-S04). +func (s *Store) PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) { + if _, err := s.db.ExecContext(ctx, + `UPDATE shift_instances SET status='published', updated_at=NOW() + WHERE year=? AND month=? AND status='draft'`, year, month, + ); err != nil { + return nil, fmt.Errorf("publish: %w", err) + } + + instances, err := s.ListInstances(ctx, year, month, 0) + if err != nil { + return nil, err + } + + byVol := make(map[int64][]ShiftInstance) + for _, inst := range instances { + for _, v := range inst.Volunteers { + byVol[v.VolunteerID] = append(byVol[v.VolunteerID], inst) + } + } + return byVol, nil +} + +// UnpublishMonth marks all published instances for the month back to draft and +// returns volunteer IDs who had assignments (for notifications FR-S05). +func (s *Store) UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) { + // Collect affected volunteer IDs before unpublishing + rows, err := s.db.QueryContext(ctx, + `SELECT DISTINCT siv.volunteer_id + FROM shift_instance_volunteers siv + JOIN shift_instances si ON siv.instance_id = si.id + WHERE si.year=? AND si.month=? AND si.status='published'`, year, month, + ) + if err != nil { + return nil, fmt.Errorf("query volunteers: %w", err) + } + defer rows.Close() + + var volunteerIDs []int64 + for rows.Next() { + var vid int64 + if err := rows.Scan(&vid); err != nil { + return nil, err + } + volunteerIDs = append(volunteerIDs, vid) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if _, err := s.db.ExecContext(ctx, + `UPDATE shift_instances SET status='draft', updated_at=NOW() + WHERE year=? AND month=? AND status='published'`, year, month, + ); err != nil { + return nil, fmt.Errorf("unpublish: %w", err) + } + + return volunteerIDs, nil +} + +// ConfirmShift marks a volunteer's attendance confirmation for a shift (FR-S06). +func (s *Store) ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error { + result, err := s.db.ExecContext(ctx, + `UPDATE shift_instance_volunteers SET confirmed=1, confirmed_at=NOW() + WHERE instance_id=? AND volunteer_id=?`, instanceID, volunteerID, + ) + if err != nil { + return fmt.Errorf("confirm shift: %w", err) + } + affected, _ := result.RowsAffected() + if affected == 0 { + return ErrNotFound + } + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]TemplateRole, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, template_id, role_name, count FROM shift_template_roles WHERE template_id = ?`, templateID) + if err != nil { + return nil, fmt.Errorf("get template roles: %w", err) + } + defer rows.Close() + var roles []TemplateRole + for rows.Next() { + var r TemplateRole + if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil { + return nil, err + } + roles = append(roles, r) + } + return roles, rows.Err() +} + +func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]int64, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT volunteer_id FROM shift_template_volunteers WHERE template_id = ?`, templateID) + if err != nil { + return nil, fmt.Errorf("get template volunteers: %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 (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]InstanceVolunteer, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT siv.instance_id, siv.volunteer_id, v.name, siv.confirmed, siv.confirmed_at + FROM shift_instance_volunteers siv + JOIN volunteers v ON v.id = siv.volunteer_id + WHERE siv.instance_id = ? + ORDER BY v.name`, instanceID, + ) + if err != nil { + return nil, fmt.Errorf("get instance volunteers: %w", err) + } + defer rows.Close() + var vols []InstanceVolunteer + for rows.Next() { + var iv InstanceVolunteer + var confirmedAt sql.NullString + if err := rows.Scan(&iv.InstanceID, &iv.VolunteerID, &iv.Name, &iv.Confirmed, &confirmedAt); err != nil { + return nil, err + } + if confirmedAt.Valid { + t, _ := time.Parse("2006-01-02 15:04:05", confirmedAt.String) + iv.ConfirmedAt = &t + } + vols = append(vols, iv) + } + return vols, rows.Err() +} + +func upsertTemplateRoles(ctx context.Context, tx *sql.Tx, templateID int64, roles []TemplateRole) error { + for _, r := range roles { + if _, err := tx.ExecContext(ctx, + `INSERT INTO shift_template_roles (template_id, role_name, count) VALUES (?, ?, ?)`, + templateID, r.RoleName, r.Count, + ); err != nil { + return fmt.Errorf("insert role: %w", err) + } + } + return nil +} + +func upsertTemplateVolunteers(ctx context.Context, tx *sql.Tx, templateID int64, volunteerIDs []int64) error { + for _, vid := range volunteerIDs { + if _, err := tx.ExecContext(ctx, + `INSERT IGNORE INTO shift_template_volunteers (template_id, volunteer_id) VALUES (?, ?)`, + templateID, vid, + ); err != nil { + return fmt.Errorf("insert template volunteer: %w", err) + } + } + return nil +} + +type instanceScanner interface { + Scan(dest ...any) error +} + +func scanInstance(r instanceScanner) (*ShiftInstance, error) { + var inst ShiftInstance + var templateID sql.NullInt64 + var createdAt, updatedAt string + if err := r.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime, + &inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month, + &createdAt, &updatedAt); err != nil { + return nil, err + } + if templateID.Valid { + inst.TemplateID = &templateID.Int64 + } + inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + if inst.Volunteers == nil { + inst.Volunteers = []InstanceVolunteer{} + } + return &inst, nil +} + +func scanInstanceRow(row *sql.Row) (*ShiftInstance, error) { + var inst ShiftInstance + var templateID sql.NullInt64 + var createdAt, updatedAt string + if err := row.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime, + &inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month, + &createdAt, &updatedAt); err != nil { + return nil, err + } + if templateID.Valid { + inst.TemplateID = &templateID.Int64 + } + inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + if inst.Volunteers == nil { + inst.Volunteers = []InstanceVolunteer{} + } + return &inst, nil +} + +func daysIn(year, month int) int { + return time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC).Day() +} diff --git a/internal/server/server.go b/internal/server/server.go index c4165b6..104967c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,8 +22,11 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { volunteerStore := volunteer.NewStore(db) volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc) + notificationStore := notification.NewStore(db) + notificationHandler := notification.NewHandler(notificationStore) + scheduleStore := schedule.NewStore(db) - scheduleHandler := schedule.NewHandler(scheduleStore) + scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore) timeoffStore := timeoff.NewStore(db) timeoffHandler := timeoff.NewHandler(timeoffStore) @@ -31,9 +34,6 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { checkinStore := checkin.NewStore(db) checkinHandler := checkin.NewHandler(checkinStore) - notificationStore := notification.NewStore(db) - notificationHandler := notification.NewHandler(notificationStore) - r := chi.NewRouter() r.Use(chimiddleware.Logger) r.Use(chimiddleware.Recoverer) @@ -56,11 +56,19 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler { r.Put("/volunteers/{id}", volunteerHandler.Update) r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite) - // Schedules - r.Get("/schedules", scheduleHandler.List) - r.Post("/schedules", scheduleHandler.Create) - r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update) - r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete) + // Shift templates (admin only) + r.Get("/shift-templates", scheduleHandler.ListTemplates) + r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate) + r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate) + r.With(middleware.RequireAdmin).Delete("/shift-templates/{id}", scheduleHandler.DeleteTemplate) + + // Shift instances + r.Get("/shifts", scheduleHandler.ListInstances) + r.With(middleware.RequireAdmin).Post("/shifts/generate", scheduleHandler.GenerateInstances) + r.With(middleware.RequireAdmin).Post("/shifts/publish", scheduleHandler.PublishMonth) + r.With(middleware.RequireAdmin).Post("/shifts/unpublish", scheduleHandler.UnpublishMonth) + r.With(middleware.RequireAdmin).Put("/shifts/{id}", scheduleHandler.UpdateInstance) + r.Post("/shifts/{id}/confirm", scheduleHandler.ConfirmShift) // Time off r.Get("/timeoff", timeoffHandler.List) diff --git a/web/src/api.ts b/web/src/api.ts index f1f175d..36dfe4b 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -38,12 +38,26 @@ export const api = { resendInvite: (id: number) => request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}), - // Schedules - listSchedules: () => request('GET', '/schedules'), - createSchedule: (data: CreateScheduleInput) => request('POST', '/schedules', data), - updateSchedule: (id: number, data: Partial) => - request('PUT', `/schedules/${id}`, data), - deleteSchedule: (id: number) => request('DELETE', `/schedules/${id}`), + // Shift templates + listShiftTemplates: () => request('GET', '/shift-templates'), + createShiftTemplate: (data: CreateShiftTemplateInput) => + request('POST', '/shift-templates', data), + updateShiftTemplate: (id: number, data: Partial) => + request('PUT', `/shift-templates/${id}`, data), + deleteShiftTemplate: (id: number) => request('DELETE', `/shift-templates/${id}`), + + // Shift instances + listShifts: (year: number, month: number) => + request('GET', `/shifts?year=${year}&month=${month}`), + generateShifts: (year: number, month: number) => + request('POST', '/shifts/generate', { year, month }), + publishShifts: (year: number, month: number) => + request<{ year: number; month: number }>('POST', '/shifts/publish', { year, month }), + unpublishShifts: (year: number, month: number) => + request<{ year: number; month: number }>('POST', '/shifts/unpublish', { year, month }), + updateShift: (id: number, data: UpdateShiftInput) => + request('PUT', `/shifts/${id}`, data), + confirmShift: (id: number) => request('POST', `/shifts/${id}/confirm`, {}), // Time off listTimeOff: () => request('GET', '/timeoff'), @@ -104,23 +118,67 @@ export interface UpdateVolunteerInput { admin_notes?: string; } -export interface Schedule { +export interface TemplateRole { + id?: number; + template_id?: number; + role_name: string; + count: number; +} + +export interface ShiftTemplate { id: number; - volunteer_id: number; - title: string; - starts_at: string; - ends_at: string; - notes?: string; + name: string; + day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + roles: TemplateRole[]; + volunteer_ids: number[]; created_at: string; updated_at: string; } -export interface CreateScheduleInput { - volunteer_id?: number; - title: string; - starts_at: string; - ends_at: string; - notes?: string; +export interface CreateShiftTemplateInput { + name: string; + day_of_week: number; + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + roles?: TemplateRole[]; + volunteer_ids?: number[]; +} + +export interface InstanceVolunteer { + instance_id: number; + volunteer_id: number; + name: string; + confirmed: boolean; + confirmed_at?: string; +} + +export interface ShiftInstance { + id: number; + template_id?: number; + name: string; + date: string; // "YYYY-MM-DD" + start_time: string; + end_time: string; + min_capacity: number; + max_capacity: number; + status: 'draft' | 'published'; + year: number; + month: number; + volunteers: InstanceVolunteer[]; + created_at: string; + updated_at: string; +} + +export interface UpdateShiftInput { + volunteer_ids?: number[]; + min_capacity?: number; + max_capacity?: number; } export interface TimeOffRequest { diff --git a/web/src/pages/Schedules.test.tsx b/web/src/pages/Schedules.test.tsx new file mode 100644 index 0000000..7e22bee --- /dev/null +++ b/web/src/pages/Schedules.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import Schedules from './Schedules'; +import { api, ShiftInstance, ShiftTemplate } from '../api'; + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + api: { + listShifts: jest.fn(), + listShiftTemplates: jest.fn(), + generateShifts: jest.fn(), + publishShifts: jest.fn(), + unpublishShifts: jest.fn(), + updateShift: jest.fn(), + confirmShift: jest.fn(), + createShiftTemplate: jest.fn(), + updateShiftTemplate: jest.fn(), + deleteShiftTemplate: jest.fn(), + }, +})); + +jest.mock('../auth', () => ({ + useAuth: jest.fn(), +})); + +const { useAuth } = require('../auth'); + +const mockDraftInstance: ShiftInstance = { + id: 1, + name: 'Morning Shift', + date: '2026-04-06', + start_time: '09:00:00', + end_time: '12:00:00', + min_capacity: 2, + max_capacity: 5, + status: 'draft', + year: 2026, + month: 4, + volunteers: [], + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', +}; + +const mockPublishedInstance: ShiftInstance = { + ...mockDraftInstance, + id: 2, + status: 'published', + volunteers: [{ instance_id: 2, volunteer_id: 10, name: 'Alice', confirmed: false }], +}; + +const mockTemplate: ShiftTemplate = { + id: 1, + name: 'Morning Shift', + day_of_week: 1, + start_time: '09:00:00', + end_time: '12:00:00', + min_capacity: 2, + max_capacity: 5, + roles: [], + volunteer_ids: [], + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z', +}; + +describe('Schedules (volunteer view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'volunteer', volunteerID: 10 }); + (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); + }); + + it('renders published shifts for a volunteer', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Morning Shift')).toBeInTheDocument(); + }); + expect(screen.getByText('published')).toBeInTheDocument(); + }); + + it('does not show admin controls', async () => { + render(); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.queryByText('Generate')).not.toBeInTheDocument(); + expect(screen.queryByText('Publish')).not.toBeInTheDocument(); + expect(screen.queryByText('Manage Templates')).not.toBeInTheDocument(); + }); +}); + +describe('Schedules (admin view)', () => { + beforeEach(() => { + useAuth.mockReturnValue({ role: 'admin', volunteerID: 1 }); + (api.listShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); + (api.listShiftTemplates as jest.Mock).mockResolvedValue([mockTemplate]); + }); + + it('shows Generate and Publish buttons when drafts exist', async () => { + render(); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.getByText('Generate')).toBeInTheDocument(); + expect(screen.getByText('Publish')).toBeInTheDocument(); + }); + + it('calls generateShifts on Generate click', async () => { + (api.generateShifts as jest.Mock).mockResolvedValue([mockDraftInstance]); + render(); + await waitFor(() => expect(screen.getByText('Generate')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Generate')); + expect(api.generateShifts).toHaveBeenCalled(); + }); + + it('calls publishShifts on Publish click', async () => { + (api.publishShifts as jest.Mock).mockResolvedValue({ year: 2026, month: 4 }); + (api.listShifts as jest.Mock) + .mockResolvedValueOnce([mockDraftInstance]) + .mockResolvedValue([{ ...mockDraftInstance, status: 'published' }]); + + render(); + await waitFor(() => expect(screen.getByText('Publish')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Publish')); + expect(api.publishShifts).toHaveBeenCalled(); + }); + + it('shows Unpublish button when all shifts are published', async () => { + (api.listShifts as jest.Mock).mockResolvedValue([mockPublishedInstance]); + render(); + await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument()); + }); + + it('shows Edit button on each shift row', async () => { + render(); + await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument()); + expect(screen.getByText('Edit')).toBeInTheDocument(); + }); + + it('opens edit form when Edit is clicked', async () => { + render(); + await waitFor(() => expect(screen.getByText('Edit')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Edit')); + expect(screen.getByText(/Edit Shift/)).toBeInTheDocument(); + }); + + it('switches to templates view', async () => { + render(); + await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Manage Templates')); + await waitFor(() => expect(screen.getByText('Shift Templates')).toBeInTheDocument()); + expect(screen.getByText('Morning Shift')).toBeInTheDocument(); + }); + + it('shows template form when New Template is clicked', async () => { + render(); + await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Manage Templates')); + await waitFor(() => expect(screen.getByText('+ New Template')).toBeInTheDocument()); + fireEvent.click(screen.getByText('+ New Template')); + expect(screen.getByText('New Template')).toBeInTheDocument(); + }); + + it('deletes a template', async () => { + (api.deleteShiftTemplate as jest.Mock).mockResolvedValue(undefined); + window.confirm = jest.fn().mockReturnValue(true); + render(); + await waitFor(() => expect(screen.getByText('Manage Templates')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Manage Templates')); + await waitFor(() => expect(screen.getAllByText('Delete')[0]).toBeInTheDocument()); + fireEvent.click(screen.getAllByText('Delete')[0]); + expect(api.deleteShiftTemplate).toHaveBeenCalledWith(1); + }); +}); diff --git a/web/src/pages/Schedules.tsx b/web/src/pages/Schedules.tsx index b83ac97..053338f 100644 --- a/web/src/pages/Schedules.tsx +++ b/web/src/pages/Schedules.tsx @@ -1,105 +1,471 @@ import React, { useEffect, useState, FormEvent } from 'react'; -import { api, Schedule } from '../api'; +import { + api, + ShiftTemplate, + ShiftInstance, + CreateShiftTemplateInput, + TemplateRole, + OPERATIONAL_ROLES, +} from '../api'; import { useAuth } from '../auth'; +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +function currentYearMonth() { + const now = new Date(); + return { year: now.getFullYear(), month: now.getMonth() + 1 }; +} + +// --------------------------------------------------------------------------- +// Template form +// --------------------------------------------------------------------------- + +function blankTemplate(): CreateShiftTemplateInput { + return { + name: '', + day_of_week: 1, + start_time: '09:00:00', + end_time: '17:00:00', + min_capacity: 1, + max_capacity: 5, + roles: [], + volunteer_ids: [], + }; +} + +interface TemplateFormProps { + initial?: Partial; + onSave: (data: CreateShiftTemplateInput) => Promise; + onCancel: () => void; + title: string; +} + +function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) { + const [form, setForm] = useState({ ...blankTemplate(), ...initial }); + const [roleRow, setRoleRow] = useState({ role_name: '', count: 1 }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + await onSave(form); + } catch (err: any) { + setError(err.message); + } finally { + setSaving(false); + } + } + + function addRole() { + if (!roleRow.role_name) return; + setForm(f => ({ ...f, roles: [...(f.roles ?? []), { ...roleRow }] })); + setRoleRow({ role_name: '', count: 1 }); + } + + function removeRole(idx: number) { + setForm(f => ({ ...f, roles: (f.roles ?? []).filter((_, i) => i !== idx) })); + } + + return ( +
+

{title}

+ {error &&

{error}

} + + + + + + + +
+ Role Requirements + {(form.roles ?? []).map((r, i) => ( +
+ {r.count}× {r.role_name} + +
+ ))} +
+ + + +
+
+ +
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + +type View = 'shifts' | 'templates'; + export default function Schedules() { const { role } = useAuth(); - const [schedules, setSchedules] = useState([]); + const [view, setView] = useState('shifts'); + + // Month navigation + const init = currentYearMonth(); + const [year, setYear] = useState(init.year); + const [month, setMonth] = useState(init.month); + + // Data + const [instances, setInstances] = useState([]); + const [templates, setTemplates] = useState([]); const [error, setError] = useState(''); - const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ title: '', starts_at: '', ends_at: '', notes: '' }); + + // UI state + const [showTemplateForm, setShowTemplateForm] = useState(false); + const [editingTemplate, setEditingTemplate] = useState(null); + const [editingInstance, setEditingInstance] = useState(null); + const [editVolIds, setEditVolIds] = useState(''); + const [editMinCap, setEditMinCap] = useState(''); + const [editMaxCap, setEditMaxCap] = useState(''); useEffect(() => { - api.listSchedules().then(setSchedules).catch(() => setError('Could not load schedules.')); - }, []); + if (view === 'shifts') { + api.listShifts(year, month) + .then(setInstances) + .catch(() => setError('Could not load shifts.')); + } else { + api.listShiftTemplates() + .then(setTemplates) + .catch(() => setError('Could not load templates.')); + } + }, [view, year, month]); - async function handleCreate(e: FormEvent) { - e.preventDefault(); + function prevMonth() { + if (month === 1) { setYear(y => y - 1); setMonth(12); } + else setMonth(m => m - 1); + } + + function nextMonth() { + if (month === 12) { setYear(y => y + 1); setMonth(1); } + else setMonth(m => m + 1); + } + + async function handleGenerate() { setError(''); try { - const sc = await api.createSchedule(form); - setSchedules(prev => [...prev, sc]); - setForm({ title: '', starts_at: '', ends_at: '', notes: '' }); - setShowForm(false); + const newInstances = await api.generateShifts(year, month); + setInstances(newInstances); } catch (err: any) { setError(err.message); } } - async function handleDelete(id: number) { - if (!window.confirm('Delete this schedule?')) return; + async function handlePublish() { + setError(''); try { - await api.deleteSchedule(id); - setSchedules(prev => prev.filter(s => s.id !== id)); + await api.publishShifts(year, month); + const updated = await api.listShifts(year, month); + setInstances(updated); } catch (err: any) { setError(err.message); } } + async function handleUnpublish() { + if (!window.confirm(`Unpublish the ${MONTH_NAMES[month - 1]} ${year} schedule?`)) return; + setError(''); + try { + await api.unpublishShifts(year, month); + const updated = await api.listShifts(year, month); + setInstances(updated); + } catch (err: any) { + setError(err.message); + } + } + + async function handleConfirm(id: number) { + setError(''); + try { + await api.confirmShift(id); + const updated = await api.listShifts(year, month); + setInstances(updated); + } catch (err: any) { + setError(err.message); + } + } + + async function handleCreateTemplate(data: CreateShiftTemplateInput) { + const t = await api.createShiftTemplate(data); + setTemplates(prev => [...prev, t]); + setShowTemplateForm(false); + } + + async function handleUpdateTemplate(data: CreateShiftTemplateInput) { + if (!editingTemplate) return; + const t = await api.updateShiftTemplate(editingTemplate.id, data); + setTemplates(prev => prev.map(x => (x.id === t.id ? t : x))); + setEditingTemplate(null); + } + + async function handleDeleteTemplate(id: number) { + if (!window.confirm('Delete this template? Existing shifts will not be affected.')) return; + setError(''); + try { + await api.deleteShiftTemplate(id); + setTemplates(prev => prev.filter(t => t.id !== id)); + } catch (err: any) { + setError(err.message); + } + } + + function openEditInstance(inst: ShiftInstance) { + setEditingInstance(inst); + setEditVolIds(inst.volunteers.map(v => v.volunteer_id).join(',')); + setEditMinCap(String(inst.min_capacity)); + setEditMaxCap(String(inst.max_capacity)); + } + + async function handleUpdateInstance(e: FormEvent) { + e.preventDefault(); + if (!editingInstance) return; + setError(''); + try { + const volIds = editVolIds.trim() + ? editVolIds.split(',').map(s => Number(s.trim())).filter(Boolean) + : []; + const updated = await api.updateShift(editingInstance.id, { + volunteer_ids: volIds, + min_capacity: Number(editMinCap), + max_capacity: Number(editMaxCap), + }); + setInstances(prev => prev.map(i => (i.id === updated.id ? updated : i))); + setEditingInstance(null); + } catch (err: any) { + setError(err.message); + } + } + + const allPublished = instances.length > 0 && instances.every(i => i.status === 'published'); + const hasDraft = instances.some(i => i.status === 'draft'); + return (

Schedules

{role === 'admin' && ( - +
+ +
)}
+ {error &&

{error}

} - {showForm && ( -
-

New Shift

- - - -