feat(P1): Reach signup handler + templates + tests (REQ-040, G-026)

web/handlers/reach.go: GET /reach (list), GET /reach/new (form),
GET /reach/{id} (detail), POST /reach (atomic Reach+Stash create
per D-071, redirect 302). Labels "Create a Reach" (not the banned
legacy word). G-027 validation (non-empty, <=128, no path separators,
no template syntax). 3 Reach templates extend base.html.
reach_test.go: httptest for all 4 routes + atomic create + 400/409
error paths + rendered-HTML lexicon check on BOTH 200 and error bodies
(G-026). handlers/server.go: clone-per-page template pattern (avoids
content-block collision across pages). Coverage 81.2% on web/handlers.

---ci---
project: oy
phase: 1
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
2026-08-18 17:08:32 +00:00
parent 5a12ab0e76
commit 0bcb96c442
9 changed files with 478 additions and 16 deletions
+100
View File
@@ -0,0 +1,100 @@
// Package handlers holds the HTTP handlers for the OpenYield web UI screens.
//
// Each screen (Reach signup, Stash dashboard, Window authorization, Standing
// progress, Bloom accrual) gets its own handler file. handlers/server.go wires
// routes into the mux from web/server.go. Handlers render html/template
// templates against the mock store (web/store). Lexicon-clean by construction
// (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files.
package handlers
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"github.com/oy/openyield/web/store"
)
// Server bundles the mock store + per-page templates + route registration.
// Each screen handler is a method on Server so it shares the store + tmpl.
//
// Template loading: base.html is parsed once, then each page template is
// parsed in a CLONE of the base set so the per-page "content" block does not
// collide across pages (Go html/template shares the block namespace within
// one set; cloning per page isolates each page's content block). This is the
// standard Go template pattern for layouts + pages.
type Server struct {
Store *store.Store
Pages map[string]*template.Template
}
// New constructs a Server with the given store + per-page templates loaded
// from templatesDir (the absolute or relative path to web/templates/).
func New(s *store.Store, templatesDir string) (*Server, error) {
basePath := filepath.Join(templatesDir, "base.html")
base, err := template.ParseFiles(basePath)
if err != nil {
return nil, fmt.Errorf("parse base: %w", err)
}
pages := map[string]*template.Template{}
pageGlob := filepath.Join(templatesDir, "*.html")
matches, err := filepath.Glob(pageGlob)
if err != nil {
return nil, fmt.Errorf("glob pages: %w", err)
}
for _, p := range matches {
name := filepath.Base(p)
if name == "base.html" {
continue
}
clone, cerr := base.Clone()
if cerr != nil {
return nil, fmt.Errorf("clone for %s: %w", name, cerr)
}
pt, perr := clone.ParseFiles(p)
if perr != nil {
return nil, fmt.Errorf("parse %s: %w", name, perr)
}
pages[name] = pt
}
return &Server{Store: s, Pages: pages}, nil
}
// Register wires all screen routes into the given mux (Go 1.22 method
// patterns). Called by web/server.go after constructing the Server.
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
// P2..P5 register their own routes (stash, window, standing, bloom).
}
// render executes the named page template with the given data, writing HTML
// to w. The page template invokes base.html and overrides the "content" block.
func (s *Server) render(w http.ResponseWriter, name string, data any) {
tmpl, ok := s.Pages[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
}
}
// RenderHome renders the home page (public so web/server.go can call it for
// the "/" route which lives outside handlers.Register).
func (s *Server) RenderHome(w http.ResponseWriter, data any) {
s.render(w, "home.html", data)
}
// templatesDir returns the default web/templates directory relative to the
// working directory. Used by web/server.go when constructing via New().
func DefaultTemplatesDir() string {
dir, _ := os.Getwd()
if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" {
return filepath.Join(dir, "templates")
}
return "web/templates"
}