Implement Issue #1: User Accounts & Profiles
- Admin-only account creation (no self-registration); invite-token flow
replaces the public /auth/register endpoint
- New volunteer fields: phone, is_trainee, operational_roles,
notification_preference, admin_notes, last_login, completed_shifts
- Role-scoped profile editing: volunteers update name/phone only;
admins update all fields including notes and trainee flag
- /auth/activate endpoint for invite-token-based account activation
- /api/v1/volunteers/{id}/invite for admin to resend invite links
- last_login recorded on each successful authentication
Tests:
- Go: handler tests (auth rules, create, activate, update scoping) via
Storer/AuthServicer interfaces and fake store; auth unit tests for
HashPassword, IssueToken, and Parse
- Frontend: RTL tests for Activate, Profile, and Volunteers pages
- Fixed CRA 5 + React Router v7 Jest compatibility (moduleNameMapper +
TextEncoder polyfill)
- Replaced stale CRA App.test.tsx placeholder with real tests
CI:
- .gitea/workflows/ci.yml runs go vet, go test, tsc, and npm test on
every push and pull request
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,36 +2,90 @@ 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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
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"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
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"`
|
||||
Role *string `json:"role"`
|
||||
Active *bool `json:"active"`
|
||||
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 {
|
||||
@@ -42,41 +96,107 @@ func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, name, email, hashedPassword, role string) (*Volunteer, error) {
|
||||
// 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, password, role) VALUES (?, ?, ?, ?)`,
|
||||
name, email, hashedPassword, role,
|
||||
`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.GetByID(ctx, id)
|
||||
return s.getAdminByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id int64) (*Volunteer, error) {
|
||||
v := &Volunteer{}
|
||||
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
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, email, role, active, created_at, updated_at FROM volunteers WHERE id = ?`, id,
|
||||
).Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt)
|
||||
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)
|
||||
return v, nil
|
||||
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 id, name, email, role, active, created_at, updated_at FROM volunteers`
|
||||
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 active = 1`
|
||||
query += ` WHERE v.active = 1`
|
||||
}
|
||||
query += ` ORDER BY name`
|
||||
query += ` GROUP BY v.id ORDER BY v.name`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
@@ -88,43 +208,199 @@ func (s *Store) List(ctx context.Context, activeOnly bool) ([]Volunteer, error)
|
||||
for rows.Next() {
|
||||
var v Volunteer
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&v.ID, &v.Name, &v.Email, &v.Role, &v.Active, &createdAt, &updatedAt); err != nil {
|
||||
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) {
|
||||
v, err := s.GetByID(ctx, id)
|
||||
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=?, role=?, active=?, updated_at=NOW() WHERE id=?`,
|
||||
v.Name, v.Email, v.Role, activeInt, id,
|
||||
`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
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user