ProofedMesh/services/admin-api/internal/httpapi/rates.go

219 lines
6.5 KiB
Go
Raw Normal View History

// Package httpapi exposes the per_diem_rates admin surface.
// Content negotiation: Accept: text/html → HTML template, else JSON.
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
)
// Dependencies holds the pieces the handlers need; constructed in main.
type Dependencies struct {
Store store.RateStore
Templates *template.Template
Now func() time.Time
// Signer injection is omitted here; the production wiring will call
// attest.Signer around Store.Create / Store.Retire and set
// Store.AttachAttestation on success.
}
// Mount registers all rate handlers on the given mux. Admin auth middleware
// is applied by the caller in main.go — handlers themselves assume the
// request carries an authenticated admin principal in ctx.
func Mount(mux *http.ServeMux, deps Dependencies) {
mux.HandleFunc("GET /admin/per-diem-rates/{$}", deps.List)
mux.HandleFunc("POST /admin/per-diem-rates/{$}", deps.Create)
mux.HandleFunc("GET /admin/per-diem-rates/{rate_id}", deps.Get)
mux.HandleFunc("POST /admin/per-diem-rates/{rate_id}/retire", deps.Retire)
}
func (d Dependencies) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
f := store.ListFilter{
Country: r.URL.Query().Get("country"),
ActiveOnly: r.URL.Query().Get("active_only") == "true",
}
rates, err := d.Store.List(ctx, f)
if err != nil {
writeErr(w, r, http.StatusInternalServerError, err)
return
}
writeOK(w, r, http.StatusOK, map[string]any{
"rates": rates,
"filter": f,
}, "per_diem_rates_list.html")
}
func (d Dependencies) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rateID := r.PathValue("rate_id")
rate, err := d.Store.Get(ctx, rateID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, r, http.StatusNotFound, err)
return
}
writeErr(w, r, http.StatusInternalServerError, err)
return
}
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
func (d Dependencies) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
in, err := parseNewRate(r)
if err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
if err := in.Validate(); err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
rate, err := d.Store.Create(ctx, in, principalFrom(ctx), d.Now())
if err != nil {
if errors.Is(err, store.ErrOverlap) {
writeErr(w, r, http.StatusConflict, err)
return
}
writeErr(w, r, http.StatusInternalServerError, err)
return
}
// NOTE: real wiring will now call attest.Signer and attach the resulting
// hash via d.Store.AttachAttestation. Omitted in the skeleton to keep
// the handler free of HTTP calls out.
writeOK(w, r, http.StatusCreated, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
type retireReq struct {
ValidTo string `json:"valid_to"` // "YYYY-MM-DD"
}
func (d Dependencies) Retire(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rateID := r.PathValue("rate_id")
req, err := parseRetire(r)
if err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
validTo, err := time.Parse("2006-01-02", req.ValidTo)
if err != nil {
writeErr(w, r, http.StatusBadRequest, fmt.Errorf("valid_to must be YYYY-MM-DD: %w", err))
return
}
rate, err := d.Store.Retire(ctx, rateID, validTo, principalFrom(ctx), d.Now())
if err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
writeErr(w, r, http.StatusNotFound, err)
case errors.Is(err, store.ErrAlreadyRetired):
writeErr(w, r, http.StatusConflict, err)
default:
writeErr(w, r, http.StatusBadRequest, err)
}
return
}
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
func parseNewRate(r *http.Request) (model.NewRateInput, error) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var in model.NewRateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
return in, fmt.Errorf("decode body: %w", err)
}
return in, nil
}
if err := r.ParseForm(); err != nil {
return model.NewRateInput{}, fmt.Errorf("parse form: %w", err)
}
in := model.NewRateInput{
Country: r.FormValue("country"),
Currency: r.FormValue("currency"),
FullDay: r.FormValue("full_day"),
PartialDayMin8h: r.FormValue("partial_day_min8h"),
ArrivalDay: r.FormValue("arrival_day"),
DepartureDay: r.FormValue("departure_day"),
SourceRegulation: r.FormValue("source_regulation"),
}
if city := r.FormValue("city"); city != "" {
in.City = &city
}
if vf := r.FormValue("valid_from"); vf != "" {
t, err := time.Parse("2006-01-02", vf)
if err != nil {
return in, fmt.Errorf("valid_from: %w", err)
}
in.ValidFrom = t
}
if vt := r.FormValue("valid_to"); vt != "" {
t, err := time.Parse("2006-01-02", vt)
if err != nil {
return in, fmt.Errorf("valid_to: %w", err)
}
in.ValidTo = &t
}
return in, nil
}
func parseRetire(r *http.Request) (retireReq, error) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var req retireReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return req, fmt.Errorf("decode body: %w", err)
}
return req, nil
}
if err := r.ParseForm(); err != nil {
return retireReq{}, err
}
return retireReq{ValidTo: r.FormValue("valid_to")}, nil
}
func wantsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html")
}
func writeOK(w http.ResponseWriter, r *http.Request, status int, body any, template string) {
if wantsHTML(r) {
// Template rendering wired in main; in the skeleton we fall through
// to JSON for determinism in tests.
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
}
type principalKey struct{}
// WithPrincipal attaches the authenticated admin user to the request context.
// Called by the auth middleware wired in main.
func WithPrincipal(ctx context.Context, sub string) context.Context {
return context.WithValue(ctx, principalKey{}, sub)
}
func principalFrom(ctx context.Context) string {
if v, ok := ctx.Value(principalKey{}).(string); ok {
return v
}
return "unknown"
}