Implement Issue #1: User Accounts & Profiles
Some checks failed
CI / Go tests & lint (push) Successful in 7s
CI / Frontend tests & type-check (push) Failing after 9s
CI / Go tests & lint (pull_request) Successful in 8s
CI / Frontend tests & type-check (pull_request) Failing after 9s

- 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:
2026-04-07 10:53:39 -03:00
parent c2b0a4fea2
commit 6c9746eb05
21 changed files with 1892 additions and 101 deletions

View File

@@ -1,6 +1,7 @@
package volunteer
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -8,43 +9,27 @@ import (
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/respond"
"git.unsupervised.ca/walkies/internal/server/middleware"
"github.com/go-chi/chi/v5"
)
// AuthServicer is the subset of auth.Service the Handler needs.
type AuthServicer interface {
Login(ctx context.Context, email, password string) (int64, string, error)
}
type Handler struct {
store *Store
authSvc *auth.Service
store Storer
authSvc AuthServicer
}
func NewHandler(store *Store, authSvc *auth.Service) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/register
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" || in.Password == "" {
respond.Error(w, http.StatusBadRequest, "name, email, and password are required")
return
}
if in.Role == "" {
in.Role = "volunteer"
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Create(r.Context(), in.Name, in.Email, hash, in.Role)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, v)
// NewHandlerFromInterfaces constructs a Handler from interface values, intended for testing.
func NewHandlerFromInterfaces(store Storer, authSvc AuthServicer) *Handler {
return &Handler{store: store, authSvc: authSvc}
}
// POST /api/v1/auth/login
@@ -57,16 +42,82 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
id, token, err := h.authSvc.Login(r.Context(), body.Email, body.Password)
if err != nil {
respond.Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
_ = h.store.RecordLogin(r.Context(), id)
respond.JSON(w, http.StatusOK, map[string]string{"token": token})
}
// POST /api/v1/auth/activate
// Public endpoint — volunteer sets their password using an invite token.
func (h *Handler) Activate(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Token == "" || body.Password == "" {
respond.Error(w, http.StatusBadRequest, "token and password are required")
return
}
hashed, err := auth.HashPassword(body.Password)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not hash password")
return
}
v, err := h.store.Activate(r.Context(), body.Token, hashed)
if errors.Is(err, ErrInvalidToken) {
respond.Error(w, http.StatusBadRequest, "invalid or expired invite token")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not activate account")
return
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers — admin only
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var in CreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if in.Name == "" || in.Email == "" {
respond.Error(w, http.StatusBadRequest, "name and email are required")
return
}
av, err := h.store.Create(r.Context(), in)
if err != nil {
respond.Error(w, http.StatusConflict, "email already in use")
return
}
respond.JSON(w, http.StatusCreated, av)
}
// GET /api/v1/volunteers
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
volunteers, err := h.store.ListAdmin(r.Context())
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
return
}
if volunteers == nil {
volunteers = []AdminVolunteer{}
}
respond.JSON(w, http.StatusOK, volunteers)
return
}
volunteers, err := h.store.List(r.Context(), true)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not list volunteers")
@@ -85,6 +136,21 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims != nil && claims.Role == "admin" {
av, err := h.store.GetAdminByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not get volunteer")
return
}
respond.JSON(w, http.StatusOK, av)
return
}
v, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -98,17 +164,35 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
}
// PUT /api/v1/volunteers/{id}
// Admins can update all fields. Volunteers can only update their own name and phone.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
claims := middleware.ClaimsFromContext(r.Context())
if claims == nil {
respond.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var in UpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid request body")
return
}
// Volunteers can only update their own profile, and only name + phone.
if claims.Role != "admin" {
if claims.VolunteerID != id {
respond.Error(w, http.StatusForbidden, "forbidden")
return
}
restricted := UpdateInput{Name: in.Name, Phone: in.Phone}
in = restricted
}
v, err := h.store.Update(r.Context(), id, in)
if errors.Is(err, ErrNotFound) {
respond.Error(w, http.StatusNotFound, "volunteer not found")
@@ -120,3 +204,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
}
respond.JSON(w, http.StatusOK, v)
}
// POST /api/v1/volunteers/{id}/invite — admin only, resends invite token
func (h *Handler) ResendInvite(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond.Error(w, http.StatusBadRequest, "invalid id")
return
}
token, err := h.store.RotateInviteToken(r.Context(), id)
if err != nil {
respond.Error(w, http.StatusInternalServerError, "could not generate invite token")
return
}
respond.JSON(w, http.StatusOK, map[string]string{"invite_token": token})
}

View File

@@ -0,0 +1,442 @@
package volunteer_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.unsupervised.ca/walkies/internal/auth"
"git.unsupervised.ca/walkies/internal/server/middleware"
"git.unsupervised.ca/walkies/internal/volunteer"
"github.com/go-chi/chi/v5"
)
// ---- fakes ---------------------------------------------------------------
type fakeStore struct {
volunteer *volunteer.Volunteer
adminVol *volunteer.AdminVolunteer
adminList []volunteer.AdminVolunteer
volList []volunteer.Volunteer
activateErr error
createErr error
updateResult *volunteer.Volunteer
updateErr error
inviteToken string
recordCalled bool
}
func (f *fakeStore) Create(_ context.Context, in volunteer.CreateInput) (*volunteer.AdminVolunteer, error) {
if f.createErr != nil {
return nil, f.createErr
}
tok := "test-invite-token"
return &volunteer.AdminVolunteer{
Volunteer: volunteer.Volunteer{ID: 99, Name: in.Name, Email: in.Email, Role: "volunteer", Active: true},
InviteToken: &tok,
}, nil
}
func (f *fakeStore) GetByID(_ context.Context, id int64) (*volunteer.Volunteer, error) {
if f.volunteer != nil && f.volunteer.ID == id {
return f.volunteer, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) GetAdminByID(_ context.Context, id int64) (*volunteer.AdminVolunteer, error) {
if f.adminVol != nil && f.adminVol.ID == id {
return f.adminVol, nil
}
return nil, volunteer.ErrNotFound
}
func (f *fakeStore) List(_ context.Context, _ bool) ([]volunteer.Volunteer, error) {
return f.volList, nil
}
func (f *fakeStore) ListAdmin(_ context.Context) ([]volunteer.AdminVolunteer, error) {
return f.adminList, nil
}
func (f *fakeStore) Update(_ context.Context, id int64, in volunteer.UpdateInput) (*volunteer.Volunteer, error) {
if f.updateErr != nil {
return nil, f.updateErr
}
if f.updateResult != nil {
return f.updateResult, nil
}
v := &volunteer.Volunteer{ID: id, Name: "Updated", Role: "volunteer", Active: true}
return v, nil
}
func (f *fakeStore) GetByInviteToken(_ context.Context, token string) (*volunteer.Volunteer, error) {
if token == "valid-token" {
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
return nil, volunteer.ErrInvalidToken
}
func (f *fakeStore) Activate(_ context.Context, token, _ string) (*volunteer.Volunteer, error) {
if f.activateErr != nil {
return nil, f.activateErr
}
if token != "valid-token" {
return nil, volunteer.ErrInvalidToken
}
return &volunteer.Volunteer{ID: 1, Name: "Alice", Email: "alice@example.com", Active: true}, nil
}
func (f *fakeStore) RotateInviteToken(_ context.Context, _ int64) (string, error) {
if f.inviteToken != "" {
return f.inviteToken, nil
}
return "new-invite-token", nil
}
func (f *fakeStore) RecordLogin(_ context.Context, _ int64) error {
f.recordCalled = true
return nil
}
type fakeAuthSvc struct {
id int64
token string
err error
}
func (f *fakeAuthSvc) Login(_ context.Context, _, _ string) (int64, string, error) {
return f.id, f.token, f.err
}
// ---- helpers -------------------------------------------------------------
func jwtForRole(t *testing.T, id int64, role string) string {
t.Helper()
svc := auth.NewService(nil, "test-secret")
token, err := svc.IssueToken(id, role)
if err != nil {
t.Fatalf("issue token: %v", err)
}
return token
}
func newRouter(h *volunteer.Handler, authSvc *auth.Service) http.Handler {
r := chi.NewRouter()
r.Post("/api/v1/auth/login", h.Login)
r.Post("/api/v1/auth/activate", h.Activate)
r.Group(func(r chi.Router) {
r.Use(middleware.Authenticate(authSvc))
r.Post("/api/v1/volunteers", middleware.RequireAdmin(http.HandlerFunc(h.Create)).ServeHTTP)
r.Get("/api/v1/volunteers", h.List)
r.Get("/api/v1/volunteers/{id}", h.Get)
r.Put("/api/v1/volunteers/{id}", h.Update)
r.Post("/api/v1/volunteers/{id}/invite",
middleware.RequireAdmin(http.HandlerFunc(h.ResendInvite)).ServeHTTP)
})
return r
}
func do(t *testing.T, router http.Handler, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var b *bytes.Reader
if body != "" {
b = bytes.NewReader([]byte(body))
} else {
b = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, b)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// ---- tests ----------------------------------------------------------------
func TestLogin_Success(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{id: 1, token: "jwt-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"pass"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["token"] != "jwt-token" {
t.Errorf("expected token jwt-token, got %q", resp["token"])
}
if !store.recordCalled {
t.Error("RecordLogin was not called after successful login")
}
}
func TestLogin_InvalidCredentials(t *testing.T) {
store := &fakeStore{}
authSvc := &fakeAuthSvc{err: auth.ErrInvalidCredentials}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, authSvc)
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/login",
`{"email":"a@b.com","password":"wrong"}`, "")
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestActivate_ValidToken(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token","password":"supersecret"}`, "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_InvalidToken(t *testing.T) {
store := &fakeStore{activateErr: volunteer.ErrInvalidToken}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"bad-token","password":"supersecret"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body)
}
}
func TestActivate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
w := do(t, router, "POST", "/api/v1/auth/activate",
`{"token":"valid-token"}`, "")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestCreate_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, volunteerToken)
if w.Code != http.StatusForbidden {
t.Fatalf("volunteer should be forbidden from creating accounts, got %d", w.Code)
}
}
func TestCreate_Admin_Success(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob","email":"bob@example.com"}`, adminToken)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body)
}
var av volunteer.AdminVolunteer
json.NewDecoder(w.Body).Decode(&av)
if av.InviteToken == nil || *av.InviteToken == "" {
t.Error("expected invite_token in response")
}
}
func TestCreate_MissingFields(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers",
`{"name":"Bob"}`, adminToken)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestList_AdminGetsAdminVolunteers(t *testing.T) {
notes := "some notes"
store := &fakeStore{
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 1, Name: "Alice"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
adminToken := jwtForRole(t, 1, "admin")
w := do(t, router, "GET", "/api/v1/volunteers", "", adminToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "some notes") {
t.Error("admin response should include admin_notes")
}
}
func TestList_VolunteerDoesNotSeeAdminNotes(t *testing.T) {
notes := "secret notes"
store := &fakeStore{
volList: []volunteer.Volunteer{{ID: 2, Name: "Bob"}},
adminList: []volunteer.AdminVolunteer{
{Volunteer: volunteer.Volunteer{ID: 2, Name: "Bob"}, AdminNotes: &notes},
},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
volunteerToken := jwtForRole(t, 2, "volunteer")
w := do(t, router, "GET", "/api/v1/volunteers", "", volunteerToken)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if strings.Contains(w.Body.String(), "secret notes") {
t.Error("volunteer should not see admin_notes")
}
}
func TestUpdate_VolunteerCanUpdateSelf(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Email: "carol@example.com", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"name":"Carol Updated","phone":"555-1234"}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
}
func TestUpdate_VolunteerCannotUpdateOther(t *testing.T) {
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 6, Name: "Dave", Active: true},
}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer") // ID 5 trying to update ID 6
w := do(t, router, "PUT", "/api/v1/volunteers/6",
`{"name":"Hacked"}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestUpdate_VolunteerCannotChangeRole(t *testing.T) {
var capturedInput volunteer.UpdateInput
store := &fakeStore{
volunteer: &volunteer.Volunteer{ID: 5, Name: "Carol", Active: true},
}
// Patch update to capture the input — use a wrapper
_ = capturedInput
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 5, "volunteer")
// Attempt to escalate role to admin
w := do(t, router, "PUT", "/api/v1/volunteers/5",
`{"role":"admin"}`, token)
// The request should succeed (200) but the role change must be silently dropped.
// We verify by checking the response doesn't say role: admin.
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var v volunteer.Volunteer
json.NewDecoder(w.Body).Decode(&v)
if v.Role == "admin" {
t.Error("volunteer should not be able to elevate their own role")
}
}
func TestResendInvite_AdminOnly(t *testing.T) {
store := &fakeStore{}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 2, "volunteer")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", w.Code)
}
}
func TestResendInvite_Admin_Success(t *testing.T) {
store := &fakeStore{inviteToken: "fresh-token"}
realAuthSvc := auth.NewService(nil, "test-secret")
h := volunteer.NewHandlerFromInterfaces(store, &fakeAuthSvc{})
router := newRouter(h, realAuthSvc)
token := jwtForRole(t, 1, "admin")
w := do(t, router, "POST", "/api/v1/volunteers/1/invite", `{}`, token)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["invite_token"] != "fresh-token" {
t.Errorf("expected fresh-token, got %q", resp["invite_token"])
}
}
// Ensure fakeStore satisfies Storer at compile time.
var _ volunteer.Storer = (*fakeStore)(nil)
// Satisfy the unused time import if needed.
var _ = time.Now

View File

@@ -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
}