Implement Issue #2: Scheduling & Publishing
Replaces stub schedule CRUD with full shift template + instance system. - DB: add shift_templates, shift_template_roles, shift_template_volunteers, shift_instances, shift_instance_volunteers tables - schedule package: ShiftTemplate and ShiftInstance models with store (generate, publish/unpublish, per-instance edits, volunteer confirmation) - API: shift-templates CRUD + shifts generate/publish/unpublish/update/confirm - Notifications sent on publish (FR-S04), unpublish (FR-S05), instance edit (FR-S09), and volunteer added mid-month (FR-S10) - Frontend: Schedules page with month navigation, template management, publish/unpublish controls, and per-shift edit/confirm - Tests: Go handler tests (14 cases) + React tests (11 cases) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,99 +1,329 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.unsupervised.ca/walkies/internal/respond"
|
||||
"git.unsupervised.ca/walkies/internal/server/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Notifier is the subset of notification.Store the handler needs.
|
||||
type Notifier interface {
|
||||
CreateNotification(ctx context.Context, volunteerID int64, message string) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
store *Store
|
||||
store Storer
|
||||
notifier Notifier
|
||||
}
|
||||
|
||||
func NewHandler(store *Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
// Storer is the interface the Handler depends on.
|
||||
type Storer interface {
|
||||
CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error)
|
||||
GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error)
|
||||
ListTemplates(ctx context.Context) ([]ShiftTemplate, error)
|
||||
UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error)
|
||||
DeleteTemplate(ctx context.Context, id int64) error
|
||||
|
||||
GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error)
|
||||
ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error)
|
||||
GetInstance(ctx context.Context, id int64) (*ShiftInstance, error)
|
||||
UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (*ShiftInstance, []int64, error)
|
||||
PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error)
|
||||
UnpublishMonth(ctx context.Context, year, month int) ([]int64, error)
|
||||
ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error
|
||||
}
|
||||
|
||||
// GET /api/v1/schedules
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
volunteerID := int64(0)
|
||||
if claims.Role != "admin" {
|
||||
volunteerID = claims.VolunteerID
|
||||
}
|
||||
schedules, err := h.store.List(r.Context(), volunteerID)
|
||||
func NewHandler(store *Store, notifier Notifier) *Handler {
|
||||
return &Handler{store: store, notifier: notifier}
|
||||
}
|
||||
|
||||
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
|
||||
func NewHandlerFromInterfaces(store Storer, notifier Notifier) *Handler {
|
||||
return &Handler{store: store, notifier: notifier}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/v1/shift-templates
|
||||
func (h *Handler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
templates, err := h.store.ListTemplates(r.Context())
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list schedules")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list templates")
|
||||
return
|
||||
}
|
||||
if schedules == nil {
|
||||
schedules = []Schedule{}
|
||||
if templates == nil {
|
||||
templates = []ShiftTemplate{}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, schedules)
|
||||
respond.JSON(w, http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// POST /api/v1/schedules
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var in CreateInput
|
||||
// POST /api/v1/shift-templates
|
||||
func (h *Handler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
var in CreateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
in.VolunteerID = claims.VolunteerID
|
||||
}
|
||||
if in.Title == "" || in.StartsAt == "" || in.EndsAt == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "title, starts_at, and ends_at are required")
|
||||
if in.Name == "" || in.StartTime == "" || in.EndTime == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "name, start_time, and end_time are required")
|
||||
return
|
||||
}
|
||||
sc, err := h.store.Create(r.Context(), in)
|
||||
if in.MinCapacity <= 0 {
|
||||
in.MinCapacity = 1
|
||||
}
|
||||
if in.MaxCapacity < in.MinCapacity {
|
||||
in.MaxCapacity = in.MinCapacity
|
||||
}
|
||||
t, err := h.store.CreateTemplate(r.Context(), in)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create schedule")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not create template")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusCreated, sc)
|
||||
respond.JSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// PUT /api/v1/schedules/{id}
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
// PUT /api/v1/shift-templates/{id}
|
||||
func (h *Handler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var in UpdateInput
|
||||
var in UpdateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
sc, err := h.store.Update(r.Context(), id, in)
|
||||
t, err := h.store.UpdateTemplate(r.Context(), id, in)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "schedule not found")
|
||||
respond.Error(w, http.StatusNotFound, "template not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update schedule")
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update template")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, sc)
|
||||
respond.JSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/schedules/{id}
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
// DELETE /api/v1/shift-templates/{id}
|
||||
func (h *Handler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := h.store.Delete(r.Context(), id); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not delete schedule")
|
||||
if err := h.store.DeleteTemplate(r.Context(), id); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not delete template")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/v1/shifts?year=2026&month=4
|
||||
func (h *Handler) ListInstances(w http.ResponseWriter, r *http.Request) {
|
||||
year, month := parseYearMonth(r)
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
|
||||
volunteerID := int64(0)
|
||||
if claims.Role != "admin" {
|
||||
volunteerID = claims.VolunteerID
|
||||
}
|
||||
|
||||
instances, err := h.store.ListInstances(r.Context(), year, month, volunteerID)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not list shifts")
|
||||
return
|
||||
}
|
||||
if instances == nil {
|
||||
instances = []ShiftInstance{}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, instances)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/generate body: {"year":2026,"month":4}
|
||||
func (h *Handler) GenerateInstances(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
instances, err := h.store.GenerateInstances(r.Context(), body.Year, body.Month)
|
||||
if errors.Is(err, ErrAlreadyExists) {
|
||||
respond.Error(w, http.StatusConflict, "shifts already generated for this period")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not generate shifts")
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusCreated, instances)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/publish body: {"year":2026,"month":4}
|
||||
func (h *Handler) PublishMonth(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
|
||||
byVol, err := h.store.PublishMonth(r.Context(), body.Year, body.Month)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not publish schedule")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify each affected volunteer (FR-S04)
|
||||
mn := time.Month(body.Month).String()
|
||||
for vid, shifts := range byVol {
|
||||
msg := fmt.Sprintf("Your schedule for %s %d has been published. You have %d shift(s).",
|
||||
mn, body.Year, len(shifts))
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, map[string]any{
|
||||
"year": body.Year,
|
||||
"month": body.Month,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/unpublish body: {"year":2026,"month":4}
|
||||
func (h *Handler) UnpublishMonth(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.Year == 0 || body.Month < 1 || body.Month > 12 {
|
||||
respond.Error(w, http.StatusBadRequest, "valid year and month (1-12) are required")
|
||||
return
|
||||
}
|
||||
|
||||
volunteerIDs, err := h.store.UnpublishMonth(r.Context(), body.Year, body.Month)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not unpublish schedule")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify affected volunteers (FR-S05)
|
||||
mn := time.Month(body.Month).String()
|
||||
for _, vid := range volunteerIDs {
|
||||
msg := fmt.Sprintf("The schedule for %s %d has been retracted.", mn, body.Year)
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, map[string]any{
|
||||
"year": body.Year,
|
||||
"month": body.Month,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v1/shifts/{id}
|
||||
func (h *Handler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var in UpdateInstanceInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
inst, added, err := h.store.UpdateInstance(r.Context(), id, in)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "shift not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, "could not update shift")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify all volunteers on a published shift of the change (FR-S09)
|
||||
if inst.Status == "published" && in.VolunteerIDs != nil {
|
||||
for _, v := range inst.Volunteers {
|
||||
msg := fmt.Sprintf("Your shift on %s (%s–%s) has been updated. Please re-confirm.", inst.Date, inst.StartTime, inst.EndTime)
|
||||
h.notifier.CreateNotification(r.Context(), v.VolunteerID, msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Notify only newly added volunteers (FR-S10)
|
||||
if inst.Status == "published" && len(added) > 0 {
|
||||
for _, vid := range added {
|
||||
msg := fmt.Sprintf("You have been added to a shift on %s (%s–%s).", inst.Date, inst.StartTime, inst.EndTime)
|
||||
h.notifier.CreateNotification(r.Context(), vid, msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, inst)
|
||||
}
|
||||
|
||||
// POST /api/v1/shifts/{id}/confirm
|
||||
func (h *Handler) ConfirmShift(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
if err := h.store.ConfirmShift(r.Context(), id, claims.VolunteerID); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
respond.Error(w, http.StatusNotFound, "shift assignment not found")
|
||||
return
|
||||
}
|
||||
respond.Error(w, http.StatusInternalServerError, "could not confirm shift")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func parseYearMonth(r *http.Request) (year, month int) {
|
||||
now := time.Now()
|
||||
year = now.Year()
|
||||
month = int(now.Month())
|
||||
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil {
|
||||
year = y
|
||||
}
|
||||
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
|
||||
month = m
|
||||
}
|
||||
return year, month
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var ErrNotFound = fmt.Errorf("schedule not found")
|
||||
var (
|
||||
ErrNotFound = fmt.Errorf("not found")
|
||||
ErrAlreadyExists = fmt.Errorf("instances already generated for this period")
|
||||
)
|
||||
|
||||
type Schedule struct {
|
||||
ID int64 `json:"id"`
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
Title string `json:"title"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt time.Time `json:"ends_at"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// ---------------------------------------------------------------------------
|
||||
// Models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ShiftTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DayOfWeek int `json:"day_of_week"` // matches time.Weekday: 0=Sun, 6=Sat
|
||||
StartTime string `json:"start_time"` // "HH:MM:SS"
|
||||
EndTime string `json:"end_time"`
|
||||
MinCapacity int `json:"min_capacity"`
|
||||
MaxCapacity int `json:"max_capacity"`
|
||||
Roles []TemplateRole `json:"roles"`
|
||||
VolunteerIDs []int64 `json:"volunteer_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
Title string `json:"title"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Notes string `json:"notes"`
|
||||
type TemplateRole struct {
|
||||
ID int64 `json:"id"`
|
||||
TemplateID int64 `json:"template_id"`
|
||||
RoleName string `json:"role_name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Title *string `json:"title"`
|
||||
StartsAt *string `json:"starts_at"`
|
||||
EndsAt *string `json:"ends_at"`
|
||||
Notes *string `json:"notes"`
|
||||
type ShiftInstance struct {
|
||||
ID int64 `json:"id"`
|
||||
TemplateID *int64 `json:"template_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"` // "YYYY-MM-DD"
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
MinCapacity int `json:"min_capacity"`
|
||||
MaxCapacity int `json:"max_capacity"`
|
||||
Status string `json:"status"` // "draft" or "published"
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
Volunteers []InstanceVolunteer `json:"volunteers"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const timeLayout = "2006-01-02T15:04:05Z"
|
||||
type InstanceVolunteer struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
Name string `json:"name"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CreateTemplateInput struct {
|
||||
Name string `json:"name"`
|
||||
DayOfWeek int `json:"day_of_week"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
MinCapacity int `json:"min_capacity"`
|
||||
MaxCapacity int `json:"max_capacity"`
|
||||
Roles []TemplateRole `json:"roles"`
|
||||
VolunteerIDs []int64 `json:"volunteer_ids"`
|
||||
}
|
||||
|
||||
type UpdateTemplateInput struct {
|
||||
Name *string `json:"name"`
|
||||
DayOfWeek *int `json:"day_of_week"`
|
||||
StartTime *string `json:"start_time"`
|
||||
EndTime *string `json:"end_time"`
|
||||
MinCapacity *int `json:"min_capacity"`
|
||||
MaxCapacity *int `json:"max_capacity"`
|
||||
Roles []TemplateRole `json:"roles"`
|
||||
VolunteerIDs []int64 `json:"volunteer_ids"`
|
||||
}
|
||||
|
||||
type UpdateInstanceInput struct {
|
||||
VolunteerIDs *[]int64 `json:"volunteer_ids"`
|
||||
MinCapacity *int `json:"min_capacity"`
|
||||
MaxCapacity *int `json:"max_capacity"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
@@ -46,109 +107,614 @@ func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Schedule, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO schedules (volunteer_id, title, starts_at, ends_at, notes) VALUES (?, ?, ?, ?, ?)`,
|
||||
in.VolunteerID, in.Title, in.StartsAt, in.EndsAt, in.Notes,
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Store) CreateTemplate(ctx context.Context, in CreateTemplateInput) (*ShiftTemplate, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_templates (name, day_of_week, start_time, end_time, min_capacity, max_capacity)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
in.Name, in.DayOfWeek, in.StartTime, in.EndTime, in.MinCapacity, in.MaxCapacity,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert schedule: %w", err)
|
||||
return nil, fmt.Errorf("insert template: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return s.GetByID(ctx, id)
|
||||
|
||||
if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetTemplate(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id int64) (*Schedule, error) {
|
||||
sc := &Schedule{}
|
||||
var startsAt, endsAt, createdAt, updatedAt string
|
||||
var notes sql.NullString
|
||||
func (s *Store) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
|
||||
t := &ShiftTemplate{}
|
||||
var createdAt, updatedAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules WHERE id = ?`, id,
|
||||
).Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt)
|
||||
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||
FROM shift_templates WHERE id = ?`, id,
|
||||
).Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
|
||||
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get schedule: %w", err)
|
||||
return nil, fmt.Errorf("get template: %w", err)
|
||||
}
|
||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if notes.Valid {
|
||||
sc.Notes = notes.String
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
|
||||
func (s *Store) List(ctx context.Context, volunteerID int64) ([]Schedule, error) {
|
||||
query := `SELECT id, volunteer_id, title, starts_at, ends_at, notes, created_at, updated_at FROM schedules`
|
||||
args := []any{}
|
||||
if volunteerID > 0 {
|
||||
query += ` WHERE volunteer_id = ?`
|
||||
args = append(args, volunteerID)
|
||||
}
|
||||
query += ` ORDER BY starts_at`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list schedules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var schedules []Schedule
|
||||
for rows.Next() {
|
||||
var sc Schedule
|
||||
var startsAt, endsAt, createdAt, updatedAt string
|
||||
var notes sql.NullString
|
||||
if err := rows.Scan(&sc.ID, &sc.VolunteerID, &sc.Title, &startsAt, &endsAt, ¬es, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt)
|
||||
sc.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt)
|
||||
sc.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
sc.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if notes.Valid {
|
||||
sc.Notes = notes.String
|
||||
}
|
||||
schedules = append(schedules, sc)
|
||||
}
|
||||
return schedules, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Schedule, error) {
|
||||
sc, err := s.GetByID(ctx, id)
|
||||
roles, err := s.templateRoles(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title := sc.Title
|
||||
startsAt := sc.StartsAt.Format("2006-01-02 15:04:05")
|
||||
endsAt := sc.EndsAt.Format("2006-01-02 15:04:05")
|
||||
notes := sc.Notes
|
||||
t.Roles = roles
|
||||
|
||||
if in.Title != nil {
|
||||
title = *in.Title
|
||||
vids, err := s.templateVolunteerIDs(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.StartsAt != nil {
|
||||
startsAt = *in.StartsAt
|
||||
t.VolunteerIDs = vids
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListTemplates(ctx context.Context) ([]ShiftTemplate, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, day_of_week, start_time, end_time, min_capacity, max_capacity, created_at, updated_at
|
||||
FROM shift_templates ORDER BY day_of_week, start_time`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list templates: %w", err)
|
||||
}
|
||||
if in.EndsAt != nil {
|
||||
endsAt = *in.EndsAt
|
||||
defer rows.Close()
|
||||
|
||||
var templates []ShiftTemplate
|
||||
for rows.Next() {
|
||||
var t ShiftTemplate
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.DayOfWeek, &t.StartTime, &t.EndTime,
|
||||
&t.MinCapacity, &t.MaxCapacity, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
templates = append(templates, t)
|
||||
}
|
||||
if in.Notes != nil {
|
||||
notes = *in.Notes
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
`UPDATE schedules SET title=?, starts_at=?, ends_at=?, notes=?, updated_at=NOW() WHERE id=?`,
|
||||
title, startsAt, endsAt, notes, id,
|
||||
|
||||
// Load roles and volunteers for each template
|
||||
for i := range templates {
|
||||
roles, err := s.templateRoles(ctx, templates[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[i].Roles = roles
|
||||
|
||||
vids, err := s.templateVolunteerIDs(ctx, templates[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[i].VolunteerIDs = vids
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTemplate(ctx context.Context, id int64, in UpdateTemplateInput) (*ShiftTemplate, error) {
|
||||
t, err := s.GetTemplate(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
name := t.Name
|
||||
dow := t.DayOfWeek
|
||||
startTime := t.StartTime
|
||||
endTime := t.EndTime
|
||||
minCap := t.MinCapacity
|
||||
maxCap := t.MaxCapacity
|
||||
|
||||
if in.Name != nil {
|
||||
name = *in.Name
|
||||
}
|
||||
if in.DayOfWeek != nil {
|
||||
dow = *in.DayOfWeek
|
||||
}
|
||||
if in.StartTime != nil {
|
||||
startTime = *in.StartTime
|
||||
}
|
||||
if in.EndTime != nil {
|
||||
endTime = *in.EndTime
|
||||
}
|
||||
if in.MinCapacity != nil {
|
||||
minCap = *in.MinCapacity
|
||||
}
|
||||
if in.MaxCapacity != nil {
|
||||
maxCap = *in.MaxCapacity
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE shift_templates SET name=?, day_of_week=?, start_time=?, end_time=?,
|
||||
min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
|
||||
name, dow, startTime, endTime, minCap, maxCap, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update schedule: %w", err)
|
||||
return nil, fmt.Errorf("update template: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
|
||||
if in.Roles != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_roles WHERE template_id = ?`, id); err != nil {
|
||||
return nil, fmt.Errorf("clear roles: %w", err)
|
||||
}
|
||||
if err := upsertTemplateRoles(ctx, tx, id, in.Roles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.VolunteerIDs != nil {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM shift_template_volunteers WHERE template_id = ?`, id); err != nil {
|
||||
return nil, fmt.Errorf("clear volunteers: %w", err)
|
||||
}
|
||||
if err := upsertTemplateVolunteers(ctx, tx, id, in.VolunteerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetTemplate(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM schedules WHERE id = ?`, id)
|
||||
func (s *Store) DeleteTemplate(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM shift_templates WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instance operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateInstances creates draft shift instances for every template × date in
|
||||
// the given month. Returns ErrAlreadyExists if instances already exist for
|
||||
// that month (FR-S02).
|
||||
func (s *Store) GenerateInstances(ctx context.Context, year, month int) ([]ShiftInstance, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM shift_instances WHERE year = ? AND month = ?`, year, month,
|
||||
).Scan(&count); err != nil {
|
||||
return nil, fmt.Errorf("check existing: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, ErrAlreadyExists
|
||||
}
|
||||
|
||||
templates, err := s.ListTemplates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find all dates in the month for each template's day of week
|
||||
first := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||
daysInMonth := daysIn(year, month)
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var instanceIDs []int64
|
||||
for _, tmpl := range templates {
|
||||
for d := 0; d < daysInMonth; d++ {
|
||||
day := first.AddDate(0, 0, d)
|
||||
if int(day.Weekday()) != tmpl.DayOfWeek {
|
||||
continue
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_instances
|
||||
(template_id, name, date, start_time, end_time, min_capacity, max_capacity, status, year, month)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)`,
|
||||
tmpl.ID, tmpl.Name, day.Format("2006-01-02"),
|
||||
tmpl.StartTime, tmpl.EndTime,
|
||||
tmpl.MinCapacity, tmpl.MaxCapacity,
|
||||
year, month,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert instance: %w", err)
|
||||
}
|
||||
instID, _ := res.LastInsertId()
|
||||
instanceIDs = append(instanceIDs, instID)
|
||||
|
||||
// Copy recurring volunteer assignments from template
|
||||
for _, vid := range tmpl.VolunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
|
||||
instID, vid,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("copy volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
return s.ListInstances(ctx, year, month, 0)
|
||||
}
|
||||
|
||||
// ListInstances returns instances for a month. When volunteerID > 0, only
|
||||
// returns published instances where that volunteer is assigned.
|
||||
func (s *Store) ListInstances(ctx context.Context, year, month int, volunteerID int64) ([]ShiftInstance, error) {
|
||||
query := `SELECT id, template_id, name, date, start_time, end_time,
|
||||
min_capacity, max_capacity, status, year, month, created_at, updated_at
|
||||
FROM shift_instances WHERE year = ? AND month = ?`
|
||||
args := []any{year, month}
|
||||
|
||||
if volunteerID > 0 {
|
||||
query += ` AND status = 'published'
|
||||
AND id IN (SELECT instance_id FROM shift_instance_volunteers WHERE volunteer_id = ?)`
|
||||
args = append(args, volunteerID)
|
||||
}
|
||||
query += ` ORDER BY date, start_time`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list instances: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var instances []ShiftInstance
|
||||
for rows.Next() {
|
||||
inst, err := scanInstance(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances = append(instances, *inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range instances {
|
||||
vols, err := s.instanceVolunteers(ctx, instances[i].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
instances[i].Volunteers = vols
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetInstance(ctx context.Context, id int64) (*ShiftInstance, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, template_id, name, date, start_time, end_time,
|
||||
min_capacity, max_capacity, status, year, month, created_at, updated_at
|
||||
FROM shift_instances WHERE id = ?`, id)
|
||||
inst, err := scanInstanceRow(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance: %w", err)
|
||||
}
|
||||
vols, err := s.instanceVolunteers(ctx, inst.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inst.Volunteers = vols
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
// UpdateInstance edits volunteer assignments and/or capacity on any instance.
|
||||
// For published instances, volunteer confirmation statuses are reset (FR-S09).
|
||||
// Returns the previous and new volunteer ID sets so the caller can send notifications.
|
||||
func (s *Store) UpdateInstance(ctx context.Context, id int64, in UpdateInstanceInput) (inst *ShiftInstance, added []int64, err error) {
|
||||
inst, err = s.GetInstance(ctx, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
tx, txErr := s.db.BeginTx(ctx, nil)
|
||||
if txErr != nil {
|
||||
return nil, nil, fmt.Errorf("begin tx: %w", txErr)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if in.MinCapacity != nil || in.MaxCapacity != nil {
|
||||
minCap := inst.MinCapacity
|
||||
maxCap := inst.MaxCapacity
|
||||
if in.MinCapacity != nil {
|
||||
minCap = *in.MinCapacity
|
||||
}
|
||||
if in.MaxCapacity != nil {
|
||||
maxCap = *in.MaxCapacity
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET min_capacity=?, max_capacity=?, updated_at=NOW() WHERE id=?`,
|
||||
minCap, maxCap, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("update capacity: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if in.VolunteerIDs != nil {
|
||||
// Determine newly added volunteers (FR-S10)
|
||||
existing := make(map[int64]bool)
|
||||
for _, v := range inst.Volunteers {
|
||||
existing[v.VolunteerID] = true
|
||||
}
|
||||
for _, vid := range *in.VolunteerIDs {
|
||||
if !existing[vid] {
|
||||
added = append(added, vid)
|
||||
}
|
||||
}
|
||||
|
||||
// Replace assignments
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM shift_instance_volunteers WHERE instance_id = ?`, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("clear volunteers: %w", err)
|
||||
}
|
||||
for _, vid := range *in.VolunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
|
||||
id, vid,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("insert volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset confirmation for published shifts (FR-S09)
|
||||
if inst.Status == "published" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE shift_instance_volunteers SET confirmed=0, confirmed_at=NULL WHERE instance_id=?`, id,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("reset confirmations: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
|
||||
inst, err = s.GetInstance(ctx, id)
|
||||
return inst, added, err
|
||||
}
|
||||
|
||||
// PublishMonth marks all draft instances for the month as published and returns
|
||||
// a map of volunteerID → []ShiftInstance for notification purposes (FR-S04).
|
||||
func (s *Store) PublishMonth(ctx context.Context, year, month int) (map[int64][]ShiftInstance, error) {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET status='published', updated_at=NOW()
|
||||
WHERE year=? AND month=? AND status='draft'`, year, month,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("publish: %w", err)
|
||||
}
|
||||
|
||||
instances, err := s.ListInstances(ctx, year, month, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byVol := make(map[int64][]ShiftInstance)
|
||||
for _, inst := range instances {
|
||||
for _, v := range inst.Volunteers {
|
||||
byVol[v.VolunteerID] = append(byVol[v.VolunteerID], inst)
|
||||
}
|
||||
}
|
||||
return byVol, nil
|
||||
}
|
||||
|
||||
// UnpublishMonth marks all published instances for the month back to draft and
|
||||
// returns volunteer IDs who had assignments (for notifications FR-S05).
|
||||
func (s *Store) UnpublishMonth(ctx context.Context, year, month int) ([]int64, error) {
|
||||
// Collect affected volunteer IDs before unpublishing
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT DISTINCT siv.volunteer_id
|
||||
FROM shift_instance_volunteers siv
|
||||
JOIN shift_instances si ON siv.instance_id = si.id
|
||||
WHERE si.year=? AND si.month=? AND si.status='published'`, year, month,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var volunteerIDs []int64
|
||||
for rows.Next() {
|
||||
var vid int64
|
||||
if err := rows.Scan(&vid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
volunteerIDs = append(volunteerIDs, vid)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instances SET status='draft', updated_at=NOW()
|
||||
WHERE year=? AND month=? AND status='published'`, year, month,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("unpublish: %w", err)
|
||||
}
|
||||
|
||||
return volunteerIDs, nil
|
||||
}
|
||||
|
||||
// ConfirmShift marks a volunteer's attendance confirmation for a shift (FR-S06).
|
||||
func (s *Store) ConfirmShift(ctx context.Context, instanceID, volunteerID int64) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE shift_instance_volunteers SET confirmed=1, confirmed_at=NOW()
|
||||
WHERE instance_id=? AND volunteer_id=?`, instanceID, volunteerID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("confirm shift: %w", err)
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Store) templateRoles(ctx context.Context, templateID int64) ([]TemplateRole, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, template_id, role_name, count FROM shift_template_roles WHERE template_id = ?`, templateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template roles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var roles []TemplateRole
|
||||
for rows.Next() {
|
||||
var r TemplateRole
|
||||
if err := rows.Scan(&r.ID, &r.TemplateID, &r.RoleName, &r.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, r)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) templateVolunteerIDs(ctx context.Context, templateID int64) ([]int64, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT volunteer_id FROM shift_template_volunteers WHERE template_id = ?`, templateID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get template volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) instanceVolunteers(ctx context.Context, instanceID int64) ([]InstanceVolunteer, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT siv.instance_id, siv.volunteer_id, v.name, siv.confirmed, siv.confirmed_at
|
||||
FROM shift_instance_volunteers siv
|
||||
JOIN volunteers v ON v.id = siv.volunteer_id
|
||||
WHERE siv.instance_id = ?
|
||||
ORDER BY v.name`, instanceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get instance volunteers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var vols []InstanceVolunteer
|
||||
for rows.Next() {
|
||||
var iv InstanceVolunteer
|
||||
var confirmedAt sql.NullString
|
||||
if err := rows.Scan(&iv.InstanceID, &iv.VolunteerID, &iv.Name, &iv.Confirmed, &confirmedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if confirmedAt.Valid {
|
||||
t, _ := time.Parse("2006-01-02 15:04:05", confirmedAt.String)
|
||||
iv.ConfirmedAt = &t
|
||||
}
|
||||
vols = append(vols, iv)
|
||||
}
|
||||
return vols, rows.Err()
|
||||
}
|
||||
|
||||
func upsertTemplateRoles(ctx context.Context, tx *sql.Tx, templateID int64, roles []TemplateRole) error {
|
||||
for _, r := range roles {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO shift_template_roles (template_id, role_name, count) VALUES (?, ?, ?)`,
|
||||
templateID, r.RoleName, r.Count,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert role: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertTemplateVolunteers(ctx context.Context, tx *sql.Tx, templateID int64, volunteerIDs []int64) error {
|
||||
for _, vid := range volunteerIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT IGNORE INTO shift_template_volunteers (template_id, volunteer_id) VALUES (?, ?)`,
|
||||
templateID, vid,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert template volunteer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type instanceScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanInstance(r instanceScanner) (*ShiftInstance, error) {
|
||||
var inst ShiftInstance
|
||||
var templateID sql.NullInt64
|
||||
var createdAt, updatedAt string
|
||||
if err := r.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
|
||||
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if templateID.Valid {
|
||||
inst.TemplateID = &templateID.Int64
|
||||
}
|
||||
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if inst.Volunteers == nil {
|
||||
inst.Volunteers = []InstanceVolunteer{}
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
func scanInstanceRow(row *sql.Row) (*ShiftInstance, error) {
|
||||
var inst ShiftInstance
|
||||
var templateID sql.NullInt64
|
||||
var createdAt, updatedAt string
|
||||
if err := row.Scan(&inst.ID, &templateID, &inst.Name, &inst.Date, &inst.StartTime, &inst.EndTime,
|
||||
&inst.MinCapacity, &inst.MaxCapacity, &inst.Status, &inst.Year, &inst.Month,
|
||||
&createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if templateID.Valid {
|
||||
inst.TemplateID = &templateID.Int64
|
||||
}
|
||||
inst.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
inst.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
if inst.Volunteers == nil {
|
||||
inst.Volunteers = []InstanceVolunteer{}
|
||||
}
|
||||
return &inst, nil
|
||||
}
|
||||
|
||||
func daysIn(year, month int) int {
|
||||
return time.Date(year, time.Month(month+1), 0, 0, 0, 0, 0, time.UTC).Day()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user