package timeoff import ( "context" "database/sql" "errors" "fmt" "time" ) var ErrNotFound = fmt.Errorf("time off request not found") type Request struct { ID int64 `json:"id"` VolunteerID int64 `json:"volunteer_id"` StartsAt time.Time `json:"starts_at"` EndsAt time.Time `json:"ends_at"` Reason string `json:"reason,omitempty"` Status string `json:"status"` ReviewedBy *int64 `json:"reviewed_by,omitempty"` ReviewedAt *time.Time `json:"reviewed_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } 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"` } 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 } func NewStore(db *sql.DB) *Store { return &Store{db: db} } func (s *Store) Create(ctx context.Context, volunteerID int64, in CreateInput) (*Request, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO time_off_requests (volunteer_id, starts_at, ends_at, reason) VALUES (?, ?, ?, ?)`, volunteerID, in.StartsAt, in.EndsAt, in.Reason, ) if err != nil { return nil, fmt.Errorf("insert time off request: %w", err) } id, _ := res.LastInsertId() return s.GetByID(ctx, id) } func (s *Store) GetByID(ctx context.Context, id int64) (*Request, error) { req := &Request{} var startsAt, endsAt, createdAt, updatedAt string var reason sql.NullString var reviewedBy sql.NullInt64 var reviewedAt sql.NullString err := s.db.QueryRowContext(ctx, `SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at FROM time_off_requests WHERE id = ?`, id, ).Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("get time off request: %w", err) } req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) if reason.Valid { req.Reason = reason.String } if reviewedBy.Valid { req.ReviewedBy = &reviewedBy.Int64 } if reviewedAt.Valid { t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String) req.ReviewedAt = &t } return req, nil } func (s *Store) List(ctx context.Context, volunteerID int64) ([]Request, error) { query := `SELECT id, volunteer_id, starts_at, ends_at, reason, status, reviewed_by, reviewed_at, created_at, updated_at FROM time_off_requests` args := []any{} if volunteerID > 0 { query += ` WHERE volunteer_id = ?` args = append(args, volunteerID) } query += ` ORDER BY starts_at DESC` rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list time off requests: %w", err) } defer rows.Close() var requests []Request for rows.Next() { var req Request var startsAt, endsAt, createdAt, updatedAt string var reason sql.NullString var reviewedBy sql.NullInt64 var reviewedAt sql.NullString if err := rows.Scan(&req.ID, &req.VolunteerID, &startsAt, &endsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &createdAt, &updatedAt); err != nil { return nil, err } req.StartsAt, _ = time.Parse("2006-01-02 15:04:05", startsAt) req.EndsAt, _ = time.Parse("2006-01-02 15:04:05", endsAt) req.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) req.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) if reason.Valid { req.Reason = reason.String } if reviewedBy.Valid { req.ReviewedBy = &reviewedBy.Int64 } if reviewedAt.Valid { t, _ := time.Parse("2006-01-02 15:04:05", reviewedAt.String) req.ReviewedAt = &t } requests = append(requests, req) } return requests, rows.Err() } func (s *Store) Review(ctx context.Context, id, reviewerID int64, status string) (*Request, error) { _, err := s.db.ExecContext(ctx, `UPDATE time_off_requests SET status=?, reviewed_by=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?`, status, reviewerID, id, ) if err != nil { return nil, fmt.Errorf("review time off request: %w", err) } 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 }