Implement time off management (Issue #3)
Add full time-off lifecycle: create/edit/delete with shift conflict detection, auto-removal from conflicting shifts with admin notification, shift restoration on admin delete, and hard block on assigning volunteers with approved time off to shifts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,14 @@ type Request struct {
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Reason string `json:"reason"`
|
||||
VolunteerID int64 `json:"volunteer_id,omitempty"` // admin creating for another volunteer
|
||||
ConfirmConflicts bool `json:"confirm_conflicts,omitempty"` // acknowledge shift conflicts
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -33,6 +41,23 @@ type ReviewInput struct {
|
||||
Status string `json:"status"` // "approved" | "rejected"
|
||||
}
|
||||
|
||||
// ConflictingShift is a shift instance that overlaps with a time-off period.
|
||||
type ConflictingShift struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
Name string `json:"name"`
|
||||
Date string `json:"date"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
}
|
||||
|
||||
// RemovedShift records that a volunteer was removed from a shift due to time off.
|
||||
type RemovedShift struct {
|
||||
ID int64 `json:"id"`
|
||||
TimeOffID int64 `json:"time_off_id"`
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
VolunteerID int64 `json:"volunteer_id"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@@ -141,3 +166,207 @@ func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Update edits a time-off request's dates and reason.
|
||||
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Request, error) {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE time_off_requests SET starts_at=?, ends_at=?, reason=?, updated_at=NOW() WHERE id=?`,
|
||||
in.StartsAt, in.EndsAt, in.Reason, id,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update time off request: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Delete removes a time-off request.
|
||||
func (s *Store) Delete(ctx context.Context, id int64) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM time_off_requests WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete time off request: %w", err)
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConflictingShifts returns published shift instances where the volunteer is assigned
|
||||
// and the shift date falls within the given date range (inclusive).
|
||||
func (s *Store) ConflictingShifts(ctx context.Context, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT si.id, si.name, si.date, si.start_time, si.end_time
|
||||
FROM shift_instances si
|
||||
JOIN shift_instance_volunteers siv ON siv.instance_id = si.id
|
||||
WHERE siv.volunteer_id = ?
|
||||
AND si.date >= ?
|
||||
AND si.date <= ?
|
||||
ORDER BY si.date`, volunteerID, startsAt, endsAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query conflicting shifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shifts []ConflictingShift
|
||||
for rows.Next() {
|
||||
var cs ConflictingShift
|
||||
if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shifts = append(shifts, cs)
|
||||
}
|
||||
return shifts, rows.Err()
|
||||
}
|
||||
|
||||
// RemoveFromConflictingShifts removes the volunteer from shifts that overlap with
|
||||
// the time-off period and records the removals for later restoration.
|
||||
func (s *Store) RemoveFromConflictingShifts(ctx context.Context, timeOffID, volunteerID int64, startsAt, endsAt string) ([]ConflictingShift, error) {
|
||||
conflicts, err := s.ConflictingShifts(ctx, volunteerID, startsAt, endsAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(conflicts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, c := range conflicts {
|
||||
// Record the removal
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO time_off_removed_shifts (time_off_id, instance_id, volunteer_id) VALUES (?, ?, ?)`,
|
||||
timeOffID, c.InstanceID, volunteerID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("record removal: %w", err)
|
||||
}
|
||||
// Remove volunteer from shift
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM shift_instance_volunteers WHERE instance_id = ? AND volunteer_id = ?`,
|
||||
c.InstanceID, volunteerID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("remove from shift: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
// RemovedShiftsForTimeOff returns shifts from which the volunteer was removed
|
||||
// due to the given time-off request (for preview before admin deletes time off).
|
||||
func (s *Store) RemovedShiftsForTimeOff(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT si.id, si.name, si.date, si.start_time, si.end_time
|
||||
FROM time_off_removed_shifts tors
|
||||
JOIN shift_instances si ON si.id = tors.instance_id
|
||||
WHERE tors.time_off_id = ?
|
||||
ORDER BY si.date`, timeOffID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query removed shifts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shifts []ConflictingShift
|
||||
for rows.Next() {
|
||||
var cs ConflictingShift
|
||||
if err := rows.Scan(&cs.InstanceID, &cs.Name, &cs.Date, &cs.StartTime, &cs.EndTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shifts = append(shifts, cs)
|
||||
}
|
||||
return shifts, rows.Err()
|
||||
}
|
||||
|
||||
// RestoreRemovedShifts re-adds the volunteer to shifts they were removed from
|
||||
// when the given time-off request is deleted (FR-T04).
|
||||
func (s *Store) RestoreRemovedShifts(ctx context.Context, timeOffID int64) ([]ConflictingShift, error) {
|
||||
// Get the removals first
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT tors.instance_id, tors.volunteer_id, si.name, si.date, si.start_time, si.end_time
|
||||
FROM time_off_removed_shifts tors
|
||||
JOIN shift_instances si ON si.id = tors.instance_id
|
||||
WHERE tors.time_off_id = ?`, timeOffID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query removals: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type removal struct {
|
||||
instanceID int64
|
||||
volunteerID int64
|
||||
shift ConflictingShift
|
||||
}
|
||||
var removals []removal
|
||||
for rows.Next() {
|
||||
var r removal
|
||||
if err := rows.Scan(&r.instanceID, &r.volunteerID, &r.shift.Name, &r.shift.Date, &r.shift.StartTime, &r.shift.EndTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.shift.InstanceID = r.instanceID
|
||||
removals = append(removals, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(removals) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var restored []ConflictingShift
|
||||
for _, r := range removals {
|
||||
// Re-add to shift (ignore duplicate if they were re-added manually)
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT IGNORE INTO shift_instance_volunteers (instance_id, volunteer_id) VALUES (?, ?)`,
|
||||
r.instanceID, r.volunteerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore to shift: %w", err)
|
||||
}
|
||||
restored = append(restored, r.shift)
|
||||
}
|
||||
|
||||
// Clean up removal records (CASCADE will handle this on time-off delete,
|
||||
// but we clean up explicitly since we're restoring)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM time_off_removed_shifts WHERE time_off_id = ?`, timeOffID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clean removals: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return restored, nil
|
||||
}
|
||||
|
||||
// HasApprovedTimeOff checks if a volunteer has approved time off covering the given date.
|
||||
func (s *Store) HasApprovedTimeOff(ctx context.Context, volunteerID int64, date string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM time_off_requests
|
||||
WHERE volunteer_id = ? AND status = 'approved'
|
||||
AND ? >= DATE(starts_at) AND ? <= DATE(ends_at)`,
|
||||
volunteerID, date, date,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check time off: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user