Initialize slice helpers with make() instead of var declaration so empty results serialize as [] instead of null in JSON, fixing the frontend TypeError on the schedules page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
721 lines
22 KiB
Go
721 lines
22 KiB
Go
package schedule
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
ErrNotFound = fmt.Errorf("not found")
|
||
ErrAlreadyExists = fmt.Errorf("instances already generated for this period")
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 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"`
|
||
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
|
||
}
|
||
|
||
func NewStore(db *sql.DB) *Store {
|
||
return &Store{db: db}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 template: %w", err)
|
||
}
|
||
id, _ := res.LastInsertId()
|
||
|
||
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) GetTemplate(ctx context.Context, id int64) (*ShiftTemplate, error) {
|
||
t := &ShiftTemplate{}
|
||
var createdAt, updatedAt string
|
||
err := s.db.QueryRowContext(ctx,
|
||
`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 template: %w", err)
|
||
}
|
||
t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||
t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||
|
||
roles, err := s.templateRoles(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
t.Roles = roles
|
||
|
||
vids, err := s.templateVolunteerIDs(ctx, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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)
|
||
}
|
||
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 err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 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 template: %w", err)
|
||
}
|
||
|
||
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) 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()
|
||
roles := make([]TemplateRole, 0)
|
||
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()
|
||
ids := make([]int64, 0)
|
||
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()
|
||
vols := make([]InstanceVolunteer, 0)
|
||
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()
|
||
}
|