package volunteer import ( "context" "encoding/json" "errors" "net/http" "strconv" "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 Storer authSvc AuthServicer } func NewHandler(store *Store, authSvc *auth.Service) *Handler { return &Handler{store: store, authSvc: authSvc} } // 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 func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var body struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { respond.Error(w, http.StatusBadRequest, "invalid request body") return } 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") return } if volunteers == nil { volunteers = []Volunteer{} } respond.JSON(w, http.StatusOK, volunteers) } // GET /api/v1/volunteers/{id} func (h *Handler) Get(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 && 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") return } if err != nil { respond.Error(w, http.StatusInternalServerError, "could not get volunteer") return } respond.JSON(w, http.StatusOK, v) } // 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") return } if err != nil { respond.Error(w, http.StatusInternalServerError, "could not update volunteer") return } 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}) }