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 }