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

272 lines
8.6 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"
"log/slog"
"net/http"
"strings"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/attest"
2026-10-25 18:06:51 +00:00
"basicstack.de/proofedmesh/services/admin-api/internal/auth"
"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
// Attest is optional. When set, successful Create and Retire call it to
// sign a rate_change attestation and submit it to the Transparency Log,
// then persist the attestation ref on the row. When nil (e.g. in unit
// tests or in a bare dev run), handlers skip the attestation step and
// log a warning; the row stays "attestation pending" and a reconciler
// catches it later.
Attest *attest.Orchestrator
// Issuer identifies the OIDC issuer for the admin principal, used only
// when constructing attestation events. Defaults to Pocket ID's issuer.
Issuer string
Logger *slog.Logger
}
// 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
}
rate = d.attestOrLog(ctx, attest.ChangeCreate, rate)
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
}
rate = d.attestOrLog(ctx, attest.ChangeRetire, rate)
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
// attestOrLog signs a rate_change event through d.Attest and attaches the
// resulting content_hash to the row. On any failure (orchestrator missing,
// signer/log down, store attach failed), it logs and returns the rate as-is
// — the row stays "attestation pending" and a reconciliation worker retries.
func (d Dependencies) attestOrLog(ctx context.Context, kind attest.ChangeKind, rate model.Rate) model.Rate {
if d.Attest == nil {
if d.Logger != nil {
d.Logger.Warn("attest skipped: no orchestrator wired", "rate_id", rate.RateID, "kind", string(kind))
}
return rate
}
issuer := d.Issuer
if issuer == "" {
issuer = "https://auth.basicstack.de"
}
ev := attest.RateChangeEvent{
Kind: kind,
Rate: rate,
PerformedBy: attest.PerformedBy{
Issuer: issuer,
Subject: principalFrom(ctx),
SessionID: sessionFrom(ctx),
},
PerformedAt: d.Now().UTC(),
}
contentHash, _, err := d.Attest.SignAndLog(ctx, ev)
if err != nil {
if d.Logger != nil {
d.Logger.Error("attest failed; row will remain pending for reconciliation",
"rate_id", rate.RateID, "kind", string(kind), "err", err)
}
return rate
}
if err := d.Store.AttachAttestation(ctx, rate.RateID, contentHash); err != nil {
if d.Logger != nil {
d.Logger.Error("attach attestation failed; signed but not persisted",
"rate_id", rate.RateID, "content_hash", contentHash, "err", err)
}
return rate
}
rate.RateChangeAttestationRef = &contentHash
return rate
}
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()})
}
2026-10-25 18:06:51 +00:00
// WithPrincipal re-exports auth.WithPrincipal so existing callers and tests
// keep working after the context accessors moved into internal/auth.
func WithPrincipal(ctx context.Context, sub string) context.Context {
2026-10-25 18:06:51 +00:00
return auth.WithPrincipal(ctx, sub)
}
2026-10-25 18:06:51 +00:00
// WithSession re-exports auth.WithSession for the same reason.
func WithSession(ctx context.Context, sessionID string) context.Context {
2026-10-25 18:06:51 +00:00
return auth.WithSession(ctx, sessionID)
}
2026-10-25 18:06:51 +00:00
func principalFrom(ctx context.Context) string { return auth.PrincipalFrom(ctx) }
func sessionFrom(ctx context.Context) string { return auth.SessionFrom(ctx) }