286 lines
8.8 KiB
Go
286 lines
8.8 KiB
Go
// 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"
|
|
"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()})
|
|
}
|
|
|
|
type principalKey struct{}
|
|
type sessionKey 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)
|
|
}
|
|
|
|
// WithSession attaches the Pocket ID session identifier to the request context.
|
|
// Carried into rate_change attestations so audits show which session triggered
|
|
// the change.
|
|
func WithSession(ctx context.Context, sessionID string) context.Context {
|
|
return context.WithValue(ctx, sessionKey{}, sessionID)
|
|
}
|
|
|
|
func principalFrom(ctx context.Context) string {
|
|
if v, ok := ctx.Value(principalKey{}).(string); ok {
|
|
return v
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func sessionFrom(ctx context.Context) string {
|
|
if v, ok := ctx.Value(sessionKey{}).(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|