Files
walkies/internal/volunteer/volunteer.go
James Griffin 07f11fa94e 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>
2026-04-09 10:54:12 -03:00

426 lines
12 KiB
Go

package volunteer
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
var ErrNotFound = fmt.Errorf("volunteer not found")
var ErrInvalidToken = fmt.Errorf("invalid or expired invite token")
// OperationalRoles lists the valid operational role values.
var OperationalRoles = []string{
"Behaviour Team",
"Dog Log Monitor",
"Dog Shelter Volunteer",
"Trainee",
"Floater",
}
type Volunteer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Active bool `json:"active"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone,omitempty"`
OperationalRoles string `json:"operational_roles"`
NotificationPreference string `json:"notification_preference"`
LastLogin *string `json:"last_login,omitempty"`
CompletedShifts int `json:"completed_shifts"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AdminVolunteer embeds Volunteer and adds admin-only fields.
type AdminVolunteer struct {
Volunteer
AdminNotes *string `json:"admin_notes,omitempty"`
InviteToken *string `json:"invite_token,omitempty"`
}
// DisplayName returns "Name (inactive)" for deactivated volunteers.
func (v *Volunteer) DisplayName() string {
if !v.Active {
return v.Name + " (inactive)"
}
return v.Name
}
type CreateInput struct {
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsTrainee bool `json:"is_trainee"`
Phone *string `json:"phone"`
OperationalRoles string `json:"operational_roles"`
}
type UpdateInput struct {
Name *string `json:"name"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Role *string `json:"role"`
Active *bool `json:"active"`
IsTrainee *bool `json:"is_trainee"`
OperationalRoles *string `json:"operational_roles"`
NotificationPreference *string `json:"notification_preference"`
AdminNotes *string `json:"admin_notes"`
}
// Storer is the interface the Handler depends on, enabling test fakes.
type Storer interface {
Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error)
GetByID(ctx context.Context, id int64) (*Volunteer, error)
GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error)
List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
ListAdmin(ctx context.Context) ([]AdminVolunteer, error)
Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error)
GetByInviteToken(ctx context.Context, token string) (*Volunteer, error)
Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error)
RotateInviteToken(ctx context.Context, id int64) (string, error)
RecordLogin(ctx context.Context, id int64) error
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Create inserts a new volunteer with an invite token (no password yet).
func (s *Store) Create(ctx context.Context, in CreateInput) (*AdminVolunteer, error) {
token, err := generateToken()
if err != nil {
return nil, fmt.Errorf("generate invite token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
role := in.Role
if role == "" {
role = "volunteer"
}
isTrainee := 0
if in.IsTrainee {
isTrainee = 1
}
phone := (*string)(nil)
if in.Phone != nil && *in.Phone != "" {
phone = in.Phone
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO volunteers (name, email, role, is_trainee, phone, operational_roles, invite_token, invite_expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.Name, in.Email, role, isTrainee, phone, in.OperationalRoles, token, expires,
)
if err != nil {
return nil, fmt.Errorf("insert volunteer: %w", err)
}
id, _ := res.LastInsertId()
return s.getAdminByID(ctx, id)
}
func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
v, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
return &v.Volunteer, nil
}
func (s *Store) GetAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
return s.getAdminByID(ctx, id)
}
func (s *Store) getAdminByID(ctx context.Context, id int64) (*AdminVolunteer, error) {
av := &AdminVolunteer{}
v := &av.Volunteer
var createdAt, updatedAt string
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
err := s.db.QueryRowContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
WHERE v.id = ?
GROUP BY v.id`, id,
).Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get volunteer: %w", err)
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
av.Volunteer.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
return av, nil
}
func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error) {
query := `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL`
if activeOnly {
query += ` WHERE v.active = 1`
}
query += ` GROUP BY v.id ORDER BY v.name`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("list volunteers: %w", err)
}
defer rows.Close()
var volunteers []Volunteer
for rows.Next() {
var v Volunteer
var createdAt, updatedAt string
var lastLogin sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
volunteers = append(volunteers, v)
}
return volunteers, rows.Err()
}
// ListAdmin returns all volunteers (including inactive) with admin-only fields.
func (s *Store) ListAdmin(ctx context.Context) ([]AdminVolunteer, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT v.id, v.name, v.email, v.role, v.active, v.is_trainee,
v.phone, v.operational_roles, v.notification_preference,
v.last_login, v.admin_notes, v.invite_token,
v.created_at, v.updated_at,
COUNT(c.id) AS completed_shifts
FROM volunteers v
LEFT JOIN checkins c ON c.volunteer_id = v.id AND c.checked_out_at IS NOT NULL
GROUP BY v.id ORDER BY v.name`)
if err != nil {
return nil, fmt.Errorf("list volunteers (admin): %w", err)
}
defer rows.Close()
var volunteers []AdminVolunteer
for rows.Next() {
var av AdminVolunteer
v := &av.Volunteer
var createdAt, updatedAt string
var lastLogin, adminNotes, inviteToken sql.NullString
var isTrainee int
if err := rows.Scan(
&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &isTrainee,
&v.Phone, &v.OperationalRoles, &v.NotificationPreference,
&lastLogin, &adminNotes, &inviteToken,
&createdAt, &updatedAt, &v.CompletedShifts,
); err != nil {
return nil, err
}
v.IsTrainee = isTrainee == 1
v.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
v.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt)
if lastLogin.Valid {
v.LastLogin = &lastLogin.String
}
if adminNotes.Valid {
av.AdminNotes = &adminNotes.String
}
if inviteToken.Valid {
av.InviteToken = &inviteToken.String
}
volunteers = append(volunteers, av)
}
return volunteers, rows.Err()
}
func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (*Volunteer, error) {
av, err := s.getAdminByID(ctx, id)
if err != nil {
return nil, err
}
v := &av.Volunteer
if in.Name != nil {
v.Name = *in.Name
}
if in.Email != nil {
v.Email = *in.Email
}
if in.Phone != nil {
v.Phone = in.Phone
}
if in.Role != nil {
v.Role = *in.Role
}
if in.Active != nil {
v.Active = *in.Active
}
if in.IsTrainee != nil {
v.IsTrainee = *in.IsTrainee
}
if in.OperationalRoles != nil {
v.OperationalRoles = *in.OperationalRoles
}
if in.NotificationPreference != nil {
v.NotificationPreference = *in.NotificationPreference
}
if in.AdminNotes != nil {
av.AdminNotes = in.AdminNotes
}
activeInt := 0
if v.Active {
activeInt = 1
}
isTraineeInt := 0
if v.IsTrainee {
isTraineeInt = 1
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET name=?, email=?, phone=?, role=?, active=?, is_trainee=?,
operational_roles=?, notification_preference=?, admin_notes=?, updated_at=NOW() WHERE id=?`,
v.Name, v.Email, v.Phone, v.Role, activeInt, isTraineeInt,
v.OperationalRoles, v.NotificationPreference, av.AdminNotes, id,
)
if err != nil {
return nil, fmt.Errorf("update volunteer: %w", err)
}
return s.GetByID(ctx, id)
}
// GetByInviteToken returns a volunteer by their invite token if it's still valid.
func (s *Store) GetByInviteToken(ctx context.Context, token string) (*Volunteer, error) {
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW() AND active = 1`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
return s.GetByID(ctx, id)
}
// Activate sets a volunteer's password and clears the invite token.
func (s *Store) Activate(ctx context.Context, token, hashedPassword string) (*Volunteer, error) {
// Look up the ID first while the token is still valid.
var id int64
err := s.db.QueryRowContext(ctx,
`SELECT id FROM volunteers WHERE invite_token = ? AND invite_expires_at > NOW()`,
token,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidToken
}
if err != nil {
return nil, fmt.Errorf("lookup invite token: %w", err)
}
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET password=?, invite_token=NULL, invite_expires_at=NULL, updated_at=NOW()
WHERE id=?`,
hashedPassword, id,
)
if err != nil {
return nil, fmt.Errorf("activate account: %w", err)
}
return s.GetByID(ctx, id)
}
// RotateInviteToken generates a new invite token for a volunteer.
func (s *Store) RotateInviteToken(ctx context.Context, id int64) (string, error) {
token, err := generateToken()
if err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
expires := time.Now().Add(72 * time.Hour)
_, err = s.db.ExecContext(ctx,
`UPDATE volunteers SET invite_token=?, invite_expires_at=?, updated_at=NOW() WHERE id=?`,
token, expires, id,
)
if err != nil {
return "", fmt.Errorf("rotate invite token: %w", err)
}
return token, nil
}
// RecordLogin updates last_login for a volunteer.
func (s *Store) RecordLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`UPDATE volunteers SET last_login=NOW(), updated_at=NOW() WHERE id=?`, id,
)
return err
}
// ListAdminIDs returns the IDs of all active admin users.
func (s *Store) ListAdminIDs(ctx context.Context) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id FROM volunteers WHERE role = 'admin' AND active = 1`)
if err != nil {
return nil, fmt.Errorf("list admin IDs: %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 generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}