Scan datetime columns directly into time.Time instead of strings in the timeoff store — the intermediate string parse silently failed with parseTime=true, producing zero-value dates. Display dates with month names and filter admin's own entry from the volunteer dropdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
361 lines
11 KiB
Go
361 lines
11 KiB
Go
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 reason sql.NullString
|
|
var reviewedBy sql.NullInt64
|
|
var reviewedAt sql.NullTime
|
|
|
|
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, &req.StartsAt, &req.EndsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &req.CreatedAt, &req.UpdatedAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get time off request: %w", err)
|
|
}
|
|
if reason.Valid {
|
|
req.Reason = reason.String
|
|
}
|
|
if reviewedBy.Valid {
|
|
req.ReviewedBy = &reviewedBy.Int64
|
|
}
|
|
if reviewedAt.Valid {
|
|
req.ReviewedAt = &reviewedAt.Time
|
|
}
|
|
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 reason sql.NullString
|
|
var reviewedBy sql.NullInt64
|
|
var reviewedAt sql.NullTime
|
|
if err := rows.Scan(&req.ID, &req.VolunteerID, &req.StartsAt, &req.EndsAt, &reason, &req.Status, &reviewedBy, &reviewedAt, &req.CreatedAt, &req.UpdatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if reason.Valid {
|
|
req.Reason = reason.String
|
|
}
|
|
if reviewedBy.Valid {
|
|
req.ReviewedBy = &reviewedBy.Int64
|
|
}
|
|
if reviewedAt.Valid {
|
|
req.ReviewedAt = &reviewedAt.Time
|
|
}
|
|
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
|
|
}
|