Implement Issue #2: Scheduling & Publishing #10
@@ -77,5 +77,62 @@ var statements = []string{
|
|||||||
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
FOREIGN KEY (volunteer_id) REFERENCES volunteers(id) ON DELETE CASCADE,
|
||||||
INDEX idx_volunteer_id (volunteer_id),
|
INDEX idx_volunteer_id (volunteer_id),
|
||||||
INDEX idx_read (read)
|
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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func (s *Store) ListForVolunteer(ctx context.Context, volunteerID int64) ([]Noti
|
|||||||
return notifications, rows.Err()
|
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) {
|
func (s *Store) MarkRead(ctx context.Context, id, volunteerID int64) (*Notification, error) {
|
||||||
result, err := s.db.ExecContext(ctx,
|
result, err := s.db.ExecContext(ctx,
|
||||||
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
|
`UPDATE notifications SET read = 1 WHERE id = ? AND volunteer_id = ?`,
|
||||||
|
|||||||
@@ -1,99 +1,329 @@
|
|||||||
package schedule
|
package schedule
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.unsupervised.ca/walkies/internal/respond"
|
"git.unsupervised.ca/walkies/internal/respond"
|
||||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
type Handler struct {
|
||||||
store *Store
|
store Storer
|
||||||
|
notifier Notifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store *Store) *Handler {
|
// Storer is the interface the Handler depends on.
|
||||||
return &Handler{store: store}
|
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 NewHandler(store *Store, notifier Notifier) *Handler {
|
||||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
return &Handler{store: store, notifier: notifier}
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
}
|
||||||
volunteerID := int64(0)
|
|
||||||
if claims.Role != "admin" {
|
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||||
volunteerID = claims.VolunteerID
|
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
|
||||||
}
|
return &Handler{store: store, notifier: notifier}
|
||||||
schedules, err := h.store.List(r.Context(), volunteerID)
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
|
respond.Error(w, http.StatusInternalServerError, "could not list templates")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if schedules == nil {
|
if templates == nil {
|
||||||
schedules = []Schedule{}
|
templates = []ShiftTemplate{}
|
||||||
}
|
}
|
||||||
respond.JSON(w, http.StatusOK, schedules)
|
respond.JSON(w, http.StatusOK, templates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/v1/schedules
|
// POST /api/v1/shift-templates
|
||||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
var in CreateTemplateInput
|
||||||
var in CreateInput
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if claims.Role != "admin" {
|
if in.Name == "" || in.StartTime == "" || in.EndTime == "" {
|
||||||
in.VolunteerID = claims.VolunteerID
|
respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
|
||||||
}
|
|
||||||
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
|
|
||||||
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
|
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
|
respond.Error(w, http.StatusInternalServerError, "could not create template")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond.JSON(w, http.StatusCreated, sc)
|
respond.JSON(w, http.StatusCreated, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/v1/schedules/{id}
|
// PUT /api/v1/shift-templates/{id}
|
||||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var in UpdateInput
|
var in UpdateTemplateInput
|
||||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sc, err := h.store.Update(r.Context(), id, in)
|
t, err := h.store.UpdateTemplate(r.Context(), id, in)
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
respond.Error(w, http.StatusNotFound, "schedule not found")
|
respond.Error(w, http.StatusNotFound, "template not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
|
respond.Error(w, http.StatusInternalServerError, "could not update template")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond.JSON(w, http.StatusOK, sc)
|
respond.JSON(w, http.StatusOK, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/v1/schedules/{id}
|
// DELETE /api/v1/shift-templates/{id}
|
||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
if err := h.store.DeleteTemplate(r.Context(), id); err != nil {
|
||||||
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
|
respond.Error(w, http.StatusInternalServerError, "could not delete template")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
||||||
|
}
|
||||||
|
|||||||
476
internal/schedule/handler_test.go
Normal file
476
internal/schedule/handler_test.go
Normal file
@@ -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)
|
||||||
@@ -8,35 +8,96 @@ import (
|
|||||||
"time"
|
"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 {
|
// ---------------------------------------------------------------------------
|
||||||
|
// Models
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ShiftTemplate struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
VolunteerID int64 `json:"volunteer_id"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat
|
||||||
StartsAt time.Time `json:"starts_at"`
|
StartTime string `json:"start_time"` // "HH:MM:SS"
|
||||||
EndsAt time.Time `json:"ends_at"`
|
EndTime string `json:"end_time"`
|
||||||
Notes string `json:"notes,omitempty"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateInput struct {
|
type TemplateRole struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TemplateID int64 `json:"template_id"`
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceVolunteer struct {
|
||||||
|
InstanceID int64 `json:"instance_id"`
|
||||||
VolunteerID int64 `json:"volunteer_id"`
|
VolunteerID int64 `json:"volunteer_id"`
|
||||||
Title string `json:"title"`
|
Name string `json:"name"`
|
||||||
StartsAt string `json:"starts_at"`
|
Confirmed bool `json:"confirmed"`
|
||||||
EndsAt string `json:"ends_at"`
|
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||||
Notes string `json:"notes"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateInput struct {
|
// ---------------------------------------------------------------------------
|
||||||
Title *string `json:"title"`
|
// Input types
|
||||||
StartsAt *string `json:"starts_at"`
|
// ---------------------------------------------------------------------------
|
||||||
EndsAt *string `json:"ends_at"`
|
|
||||||
Notes *string `json:"notes"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeLayout = "2006-01-02T15:04:05Z"
|
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 {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
@@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store {
|
|||||||
return &Store{db: db}
|
return &Store{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) {
|
// ---------------------------------------------------------------------------
|
||||||
res, err := s.db.ExecContext(ctx,
|
// Template operations
|
||||||
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
|
// ---------------------------------------------------------------------------
|
||||||
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("insert schedule: %w", err)
|
return nil, fmt.Errorf("insert template: %w", err)
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
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) {
|
func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
|
||||||
sc := &Schedule{}
|
t := &ShiftTemplate{}
|
||||||
var startsAt, endsAt, createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
var notes sql.NullString
|
|
||||||
err := s.db.QueryRowContext(ctx,
|
err := s.db.QueryRowContext(ctx,
|
||||||
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id,
|
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||||
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt)
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
|
||||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
roles, err := s.templateRoles(ctx, id)
|
||||||
if notes.Valid {
|
if err != nil {
|
||||||
sc.Notes = notes.String
|
return nil, err
|
||||||
}
|
}
|
||||||
return sc, nil
|
t.Roles = roles
|
||||||
|
|
||||||
|
vids, err := s.templateVolunteerIDs(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.VolunteerIDs = vids
|
||||||
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) {
|
func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) {
|
||||||
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
|
rows, err := s.db.QueryContext(ctx,
|
||||||
args := []any{}
|
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||||
if volunteerID > 0 {
|
FROM shift_templates ORDER BY day_of_week, start_time`)
|
||||||
query += ` WHERE volunteer_id = ?`
|
|
||||||
args = append(args, volunteerID)
|
|
||||||
}
|
|
||||||
query += ` ORDER BY starts_at`
|
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list schedules: %w", err)
|
return nil, fmt.Errorf("list templates: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var schedules []Schedule
|
var templates []ShiftTemplate
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sc Schedule
|
var t ShiftTemplate
|
||||||
var startsAt, endsAt, createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
var notes sql.NullString
|
if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
|
||||||
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil {
|
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
templates = append(templates, t)
|
||||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
|
||||||
if notes.Valid {
|
|
||||||
sc.Notes = notes.String
|
|
||||||
}
|
}
|
||||||
schedules = append(schedules, sc)
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return schedules, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) {
|
// Load roles and volunteers for each template
|
||||||
sc, err := s.GetByID(ctx, id)
|
for i := range templates {
|
||||||
|
roles, err := s.templateRoles(ctx, templates[i].ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
title := sc.Title
|
templates[i].Roles = roles
|
||||||
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
|
|
||||||
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
|
|
||||||
notes := sc.Notes
|
|
||||||
|
|
||||||
if in.Title != nil {
|
vids, err := s.templateVolunteerIDs(ctx, templates[i].ID)
|
||||||
title = *in.Title
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if in.StartsAt != nil {
|
templates[i].VolunteerIDs = vids
|
||||||
startsAt = *in.StartsAt
|
|
||||||
}
|
}
|
||||||
if in.EndsAt != nil {
|
return templates, nil
|
||||||
endsAt = *in.EndsAt
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if in.Notes != nil {
|
|
||||||
notes = *in.Notes
|
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
|
||||||
}
|
}
|
||||||
_, err = s.db.ExecContext(ctx,
|
if in.DayOfWeek != nil {
|
||||||
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`,
|
dow = *in.DayOfWeek
|
||||||
title, startsAt, endsAt, notes, id,
|
}
|
||||||
|
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 {
|
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 {
|
func (s *Store) DeleteTemplate(ctx context.Context, id int64) error {
|
||||||
_, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id)
|
_, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id)
|
||||||
return err
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
|||||||
volunteerStore := volunteer.NewStore(db)
|
volunteerStore := volunteer.NewStore(db)
|
||||||
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
volunteerHandler := volunteer.NewHandler(volunteerStore, authSvc)
|
||||||
|
|
||||||
|
notificationStore := notification.NewStore(db)
|
||||||
|
notificationHandler := notification.NewHandler(notificationStore)
|
||||||
|
|
||||||
scheduleStore := schedule.NewStore(db)
|
scheduleStore := schedule.NewStore(db)
|
||||||
scheduleHandler := schedule.NewHandler(scheduleStore)
|
scheduleHandler := schedule.NewHandler(scheduleStore, notificationStore)
|
||||||
|
|
||||||
timeoffStore := timeoff.NewStore(db)
|
timeoffStore := timeoff.NewStore(db)
|
||||||
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
timeoffHandler := timeoff.NewHandler(timeoffStore)
|
||||||
@@ -31,9 +34,6 @@ func New(db *sql.DB, jwtSecret string, staticDir string) http.Handler {
|
|||||||
checkinStore := checkin.NewStore(db)
|
checkinStore := checkin.NewStore(db)
|
||||||
checkinHandler := checkin.NewHandler(checkinStore)
|
checkinHandler := checkin.NewHandler(checkinStore)
|
||||||
|
|
||||||
notificationStore := notification.NewStore(db)
|
|
||||||
notificationHandler := notification.NewHandler(notificationStore)
|
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(chimiddleware.Logger)
|
r.Use(chimiddleware.Logger)
|
||||||
r.Use(chimiddleware.Recoverer)
|
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.Put("/volunteers/{id}", volunteerHandler.Update)
|
||||||
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
|
r.With(middleware.RequireAdmin).Post("/volunteers/{id}/invite", volunteerHandler.ResendInvite)
|
||||||
|
|
||||||
// Schedules
|
// Shift templates (admin only)
|
||||||
r.Get("/schedules", scheduleHandler.List)
|
r.Get("/shift-templates", scheduleHandler.ListTemplates)
|
||||||
r.Post("/schedules", scheduleHandler.Create)
|
r.With(middleware.RequireAdmin).Post("/shift-templates", scheduleHandler.CreateTemplate)
|
||||||
r.With(middleware.RequireAdmin).Put("/schedules/{id}", scheduleHandler.Update)
|
r.With(middleware.RequireAdmin).Put("/shift-templates/{id}", scheduleHandler.UpdateTemplate)
|
||||||
r.With(middleware.RequireAdmin).Delete("/schedules/{id}", scheduleHandler.Delete)
|
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
|
// Time off
|
||||||
r.Get("/timeoff", timeoffHandler.List)
|
r.Get("/timeoff", timeoffHandler.List)
|
||||||
|
|||||||
@@ -38,12 +38,26 @@ export const api = {
|
|||||||
resendInvite: (id: number) =>
|
resendInvite: (id: number) =>
|
||||||
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
|
request<{ invite_token: string }>('POST', `/volunteers/${id}/invite`, {}),
|
||||||
|
|
||||||
// Schedules
|
// Shift templates
|
||||||
listSchedules: () => request<Schedule[]>('GET', '/schedules'),
|
listShiftTemplates: () => request<ShiftTemplate[]>('GET', '/shift-templates'),
|
||||||
createSchedule: (data: CreateScheduleInput) => request<Schedule>('POST', '/schedules', data),
|
createShiftTemplate: (data: CreateShiftTemplateInput) =>
|
||||||
updateSchedule: (id: number, data: Partial<CreateScheduleInput>) =>
|
request<ShiftTemplate>('POST', '/shift-templates', data),
|
||||||
request<Schedule>('PUT', `/schedules/${id}`, data),
|
updateShiftTemplate: (id: number, data: Partial<CreateShiftTemplateInput>) =>
|
||||||
deleteSchedule: (id: number) => request<void>('DELETE', `/schedules/${id}`),
|
request<ShiftTemplate>('PUT', `/shift-templates/${id}`, data),
|
||||||
|
deleteShiftTemplate: (id: number) => request<void>('DELETE', `/shift-templates/${id}`),
|
||||||
|
|
||||||
|
// Shift instances
|
||||||
|
listShifts: (year: number, month: number) =>
|
||||||
|
request<ShiftInstance[]>('GET', `/shifts?year=${year}&month=${month}`),
|
||||||
|
generateShifts: (year: number, month: number) =>
|
||||||
|
request<ShiftInstance[]>('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<ShiftInstance>('PUT', `/shifts/${id}`, data),
|
||||||
|
confirmShift: (id: number) => request<void>('POST', `/shifts/${id}/confirm`, {}),
|
||||||
|
|
||||||
// Time off
|
// Time off
|
||||||
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
listTimeOff: () => request<TimeOffRequest[]>('GET', '/timeoff'),
|
||||||
@@ -104,23 +118,67 @@ export interface UpdateVolunteerInput {
|
|||||||
admin_notes?: string;
|
admin_notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Schedule {
|
export interface TemplateRole {
|
||||||
|
id?: number;
|
||||||
|
template_id?: number;
|
||||||
|
role_name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
volunteer_id: number;
|
name: string;
|
||||||
title: string;
|
day_of_week: number; // 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
starts_at: string;
|
start_time: string;
|
||||||
ends_at: string;
|
end_time: string;
|
||||||
notes?: string;
|
min_capacity: number;
|
||||||
|
max_capacity: number;
|
||||||
|
roles: TemplateRole[];
|
||||||
|
volunteer_ids: number[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateScheduleInput {
|
export interface CreateShiftTemplateInput {
|
||||||
volunteer_id?: number;
|
name: string;
|
||||||
title: string;
|
day_of_week: number;
|
||||||
starts_at: string;
|
start_time: string;
|
||||||
ends_at: string;
|
end_time: string;
|
||||||
notes?: 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 {
|
export interface TimeOffRequest {
|
||||||
|
|||||||
168
web/src/pages/Schedules.test.tsx
Normal file
168
web/src/pages/Schedules.test.tsx
Normal file
@@ -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(<Schedules />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Morning Shift')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('published')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show admin controls', async () => {
|
||||||
|
render(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Unpublish')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Edit button on each shift row', async () => {
|
||||||
|
render(<Schedules />);
|
||||||
|
await waitFor(() => expect(screen.getByText('Morning Shift')).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens edit form when Edit is clicked', async () => {
|
||||||
|
render(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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(<Schedules />);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,106 +1,472 @@
|
|||||||
import React, { useEffect, useState, FormEvent } from 'react';
|
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';
|
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<CreateShiftTemplateInput>;
|
||||||
|
onSave: (data: CreateShiftTemplateInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateForm({ initial, onSave, onCancel, title }: TemplateFormProps) {
|
||||||
|
const [form, setForm] = useState<CreateShiftTemplateInput>({ ...blankTemplate(), ...initial });
|
||||||
|
const [roleRow, setRoleRow] = useState<TemplateRole>({ 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 (
|
||||||
|
<form className="card" onSubmit={handleSubmit}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Day of Week
|
||||||
|
<select value={form.day_of_week} onChange={e => setForm(f => ({ ...f, day_of_week: Number(e.target.value) }))}>
|
||||||
|
{DAY_NAMES.map((d, i) => <option key={i} value={i}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start Time
|
||||||
|
<input type="time" value={form.start_time.slice(0, 5)}
|
||||||
|
onChange={e => setForm(f => ({ ...f, start_time: e.target.value + ':00' }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End Time
|
||||||
|
<input type="time" value={form.end_time.slice(0, 5)}
|
||||||
|
onChange={e => setForm(f => ({ ...f, end_time: e.target.value + ':00' }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Min Capacity
|
||||||
|
<input type="number" min={1} value={form.min_capacity}
|
||||||
|
onChange={e => setForm(f => ({ ...f, min_capacity: Number(e.target.value) }))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max Capacity
|
||||||
|
<input type="number" min={1} value={form.max_capacity}
|
||||||
|
onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) }))} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Role Requirements</legend>
|
||||||
|
{(form.roles ?? []).map((r, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.25rem' }}>
|
||||||
|
<span>{r.count}× {r.role_name}</span>
|
||||||
|
<button type="button" className="btn-danger btn-small" onClick={() => removeRole(i)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||||
|
<label style={{ flex: 1 }}>
|
||||||
|
Role
|
||||||
|
<select value={roleRow.role_name} onChange={e => setRoleRow(r => ({ ...r, role_name: e.target.value }))}>
|
||||||
|
<option value="">Select role…</option>
|
||||||
|
{OPERATIONAL_ROLES.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Count
|
||||||
|
<input type="number" min={1} value={roleRow.count} style={{ width: '4rem' }}
|
||||||
|
onChange={e => setRoleRow(r => ({ ...r, count: Number(e.target.value) }))} />
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={addRole}>Add Role</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<button type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button type="button" onClick={onCancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type View = 'shifts' | 'templates';
|
||||||
|
|
||||||
export default function Schedules() {
|
export default function Schedules() {
|
||||||
const { role } = useAuth();
|
const { role } = useAuth();
|
||||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
const [view, setView] = useState<View>('shifts');
|
||||||
|
|
||||||
|
// Month navigation
|
||||||
|
const init = currentYearMonth();
|
||||||
|
const [year, setYear] = useState(init.year);
|
||||||
|
const [month, setMonth] = useState(init.month);
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const [instances, setInstances] = useState<ShiftInstance[]>([]);
|
||||||
|
const [templates, setTemplates] = useState<ShiftTemplate[]>([]);
|
||||||
const [error, setError] = 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<ShiftTemplate | null>(null);
|
||||||
|
const [editingInstance, setEditingInstance] = useState<ShiftInstance | null>(null);
|
||||||
|
const [editVolIds, setEditVolIds] = useState('');
|
||||||
|
const [editMinCap, setEditMinCap] = useState('');
|
||||||
|
const [editMaxCap, setEditMaxCap] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
function prevMonth() {
|
||||||
e.preventDefault();
|
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('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const sc = await api.createSchedule(form);
|
const newInstances = await api.generateShifts(year, month);
|
||||||
setSchedules(prev => [...prev, sc]);
|
setInstances(newInstances);
|
||||||
setForm({ title: '', starts_at: '', ends_at: '', notes: '' });
|
|
||||||
setShowForm(false);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
async function handlePublish() {
|
||||||
if (!window.confirm('Delete this schedule?')) return;
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.deleteSchedule(id);
|
await api.publishShifts(year, month);
|
||||||
setSchedules(prev => prev.filter(s => s.id !== id));
|
const updated = await api.listShifts(year, month);
|
||||||
|
setInstances(updated);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h2>Schedules</h2>
|
<h2>Schedules</h2>
|
||||||
{role === 'admin' && (
|
{role === 'admin' && (
|
||||||
<button onClick={() => setShowForm(v => !v)}>
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
{showForm ? 'Cancel' : 'Add Shift'}
|
<button onClick={() => setView(v => v === 'shifts' ? 'templates' : 'shifts')}>
|
||||||
|
{view === 'shifts' ? 'Manage Templates' : 'View Shifts'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{showForm && (
|
{/* ---- Templates view ---- */}
|
||||||
<form className="card" onSubmit={handleCreate}>
|
{view === 'templates' && role === 'admin' && (
|
||||||
<h3>New Shift</h3>
|
<>
|
||||||
<label>
|
<div className="page-header" style={{ marginBottom: '1rem' }}>
|
||||||
Title
|
<h3>Shift Templates</h3>
|
||||||
<input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required />
|
<button onClick={() => { setShowTemplateForm(true); setEditingTemplate(null); }}>
|
||||||
</label>
|
+ New Template
|
||||||
<label>
|
</button>
|
||||||
Starts At
|
</div>
|
||||||
<input type="datetime-local" value={form.starts_at} onChange={e => setForm(f => ({ ...f, starts_at: e.target.value }))} required />
|
|
||||||
</label>
|
{showTemplateForm && !editingTemplate && (
|
||||||
<label>
|
<TemplateForm
|
||||||
Ends At
|
title="New Template"
|
||||||
<input type="datetime-local" value={form.ends_at} onChange={e => setForm(f => ({ ...f, ends_at: e.target.value }))} required />
|
onSave={handleCreateTemplate}
|
||||||
</label>
|
onCancel={() => setShowTemplateForm(false)}
|
||||||
<label>
|
/>
|
||||||
Notes
|
|
||||||
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schedules.length === 0 ? (
|
{editingTemplate && (
|
||||||
<p>No schedules found.</p>
|
<TemplateForm
|
||||||
|
title={`Edit: ${editingTemplate.name}`}
|
||||||
|
initial={editingTemplate}
|
||||||
|
onSave={handleUpdateTemplate}
|
||||||
|
onCancel={() => setEditingTemplate(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<p>No templates yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Name</th>
|
||||||
<th>Starts</th>
|
<th>Day</th>
|
||||||
<th>Ends</th>
|
<th>Time</th>
|
||||||
<th>Notes</th>
|
<th>Capacity</th>
|
||||||
{role === 'admin' && <th>Actions</th>}
|
<th>Roles</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{schedules.map(s => (
|
{templates.map(t => (
|
||||||
<tr key={s.id}>
|
<tr key={t.id}>
|
||||||
<td>{s.title}</td>
|
<td>{t.name}</td>
|
||||||
<td>{new Date(s.starts_at).toLocaleString()}</td>
|
<td>{DAY_NAMES[t.day_of_week]}</td>
|
||||||
<td>{new Date(s.ends_at).toLocaleString()}</td>
|
<td>{t.start_time.slice(0, 5)}–{t.end_time.slice(0, 5)}</td>
|
||||||
<td>{s.notes ?? '—'}</td>
|
<td>{t.min_capacity}–{t.max_capacity}</td>
|
||||||
{role === 'admin' && (
|
<td>{(t.roles ?? []).map(r => `${r.count}× ${r.role_name}`).join(', ') || '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="btn-danger btn-small" onClick={() => handleDelete(s.id)}>Delete</button>
|
<button className="btn-small" onClick={() => { setEditingTemplate(t); setShowTemplateForm(false); }}>Edit</button>
|
||||||
|
{' '}
|
||||||
|
<button className="btn-danger btn-small" onClick={() => handleDeleteTemplate(t.id)}>Delete</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Shifts view ---- */}
|
||||||
|
{view === 'shifts' && (
|
||||||
|
<>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<button onClick={prevMonth}>‹</button>
|
||||||
|
<strong>{MONTH_NAMES[month - 1]} {year}</strong>
|
||||||
|
<button onClick={nextMonth}>›</button>
|
||||||
|
|
||||||
|
{role === 'admin' && (
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto' }}>
|
||||||
|
<button onClick={handleGenerate}>Generate</button>
|
||||||
|
{hasDraft && <button onClick={handlePublish}>Publish</button>}
|
||||||
|
{allPublished && (
|
||||||
|
<button className="btn-danger" onClick={handleUnpublish}>Unpublish</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instance edit form */}
|
||||||
|
{editingInstance && (
|
||||||
|
<form className="card" onSubmit={handleUpdateInstance}>
|
||||||
|
<h3>Edit Shift: {editingInstance.name} — {editingInstance.date}</h3>
|
||||||
|
<label>
|
||||||
|
Volunteer IDs (comma-separated)
|
||||||
|
<input value={editVolIds} onChange={e => setEditVolIds(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Min Capacity
|
||||||
|
<input type="number" min={1} value={editMinCap}
|
||||||
|
onChange={e => setEditMinCap(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max Capacity
|
||||||
|
<input type="number" min={1} value={editMaxCap}
|
||||||
|
onChange={e => setEditMaxCap(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" onClick={() => setEditingInstance(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{instances.length === 0 ? (
|
||||||
|
<p>No shifts for {MONTH_NAMES[month - 1]} {year}.{role === 'admin' && ' Use Generate to create shifts from templates.'}</p>
|
||||||
|
) : (
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Shift</th>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Volunteers</th>
|
||||||
|
<th>Capacity</th>
|
||||||
|
{role === 'admin' && <th>Actions</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{instances.map(inst => {
|
||||||
|
const myAssignment = inst.volunteers.find(() => true); // for volunteers, API already filters
|
||||||
|
const confirmed = inst.volunteers.some(v => v.confirmed);
|
||||||
|
return (
|
||||||
|
<tr key={inst.id}>
|
||||||
|
<td>{inst.date}</td>
|
||||||
|
<td>{inst.name}</td>
|
||||||
|
<td>{inst.start_time.slice(0, 5)}–{inst.end_time.slice(0, 5)}</td>
|
||||||
|
<td>
|
||||||
|
<span className={inst.status === 'published' ? 'badge-success' : 'badge-neutral'}>
|
||||||
|
{inst.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{inst.volunteers.length === 0 ? '—' : inst.volunteers.map(v => (
|
||||||
|
<span key={v.volunteer_id} title={v.confirmed ? 'Confirmed' : 'Unconfirmed'}>
|
||||||
|
{v.name}{v.confirmed ? ' ✓' : ' ⚠'}
|
||||||
|
{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
<td>{inst.volunteers.length}/{inst.max_capacity}</td>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<td>
|
||||||
|
<button className="btn-small" onClick={() => openEditInstance(inst)}>Edit</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{role !== 'admin' && inst.status === 'published' && !confirmed && myAssignment && (
|
||||||
|
<td>
|
||||||
|
<button className="btn-small" onClick={() => handleConfirm(inst.id)}>Confirm</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user