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