diff --git a/services/admin-api/README.md b/services/admin-api/README.md new file mode 100644 index 0000000..8aedad6 --- /dev/null +++ b/services/admin-api/README.md @@ -0,0 +1,104 @@ +# admin-api + +Admin-only HTTP service for ProofedMesh. Currently covers one concern: +Verwaltung der Per-Diem-Pauschalen (`per_diem_rates`), die vom +`agent-per-diem-calculator` deterministisch angewandt werden +(siehe `docs/architecture/v1.1.md` §6.2 und ADR-0003). + +**Status: Skelett.** Datenbankanbindung (pgx) und Signing-Service-Call sind +Stubs mit expliziten Fehlern; HTTP-Handler, HTML-Templates, Modell-Typen und +Store-Interface sind vollständig und getestet. Prod-Deployment folgt nach +Phase-1-Trust-Core. + +## Scope + +- Admin-Oberfläche `/admin/per-diem-rates` — Liste + Neuanlage + „Retire" (`valid_to` setzen) über HTML + HTMX-ähnlichen simple-POST-Flow. +- JSON-API hinter denselben Pfaden für Automatisierung (`Accept: application/json`). +- Jede Create/Retire-Operation erzeugt eine **Rate Change Attestation** über den Signing Service — predicateType `https://basicstack.de/attestations/rate_change/v1`. Rückgabe-Hash wird in `per_diem_rates.rate_change_attestation_ref` sowie in `per_diem_rate_changes` persistiert. +- Keine Delete-Operation. Historie ist unveränderlich (Schema-Trigger erzwingt das). + +## Auth + +- OIDC via Pocket ID (Authorization-Code-Flow, Session-Cookie). +- Autorisierung: Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token gelesen und ist Pflicht für alle Endpunkte außer Login. +- Hintergrund-Dienste rufen die API nicht — Admin-API ist Human-only. Deshalb kein `client_credentials`-Pfad. + +## HTTP-Endpunkte (geplant) + +| Methode | Pfad | Beschreibung | +| ------- | ---------------------------------------- | -------------------------------------------- | +| GET | `/admin/per-diem-rates/` | Liste aller Rates (gefiltert nach Country, aktiv/retired) | +| GET | `/admin/per-diem-rates/{rate_id}` | Detailansicht einer Rate | +| POST | `/admin/per-diem-rates/` | Neue Rate anlegen (JSON oder Form) | +| POST | `/admin/per-diem-rates/{rate_id}/retire` | `valid_to` setzen (Rate ablaufen lassen) | + +Content-Negotiation per `Accept`-Header: `text/html` → Templates, `application/json` → JSON. + +## Layout + +``` +services/admin-api/ +├── README.md — diese Datei +├── go.mod +├── cmd/admin-api/main.go — HTTP-Entry (bind + router + graceful shutdown) +├── internal/ +│ ├── model/rate.go — Rate struct, validation +│ ├── store/ +│ │ ├── store.go — RateStore interface +│ │ ├── memory.go — in-memory impl (für Tests + Dev) +│ │ ├── memory_test.go +│ │ └── postgres.go — pgx impl, derzeit Stub +│ ├── httpapi/ +│ │ ├── rates.go — Handler +│ │ └── rates_test.go — Handler-Tests gegen in-memory store +│ └── attest/ +│ └── ratechange.go — Signing-Service-Client, derzeit Stub +└── web/ + └── templates/per_diem_rates.html +``` + +## Lokal bauen (sobald Code vollständig) + +```bash +cd services/admin-api +go test ./... +# Dev-Modus mit in-memory store, kein Postgres: +go run ./cmd/admin-api -store=memory -listen=:8080 +# Prod-Modus (sobald postgres.go fertig): +go run ./cmd/admin-api -store=postgres \ + -db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full" +``` + +## Rate Change Attestation (Entwurf) + +```json +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { "name": "per_diem_rate/PDR-42", "digest": { "sha256": "" } } + ], + "predicateType": "https://basicstack.de/attestations/rate_change/v1", + "predicate": { + "change_kind": "create", + "rate_id": "PDR-42", + "country": "DE", + "city": null, + "currency": "EUR", + "full_day": "28.00", + "partial_day_min8h": "14.00", + "arrival_day": "14.00", + "departure_day": "14.00", + "valid_from": "2026-01-01", + "valid_to": null, + "source_regulation": "BMF-Schreiben …", + "performed_by": { + "iss": "https://auth.basicstack.de", + "sub": "user-admin-42", + "session_id": "SES-admin-…" + }, + "performed_at": "2026-10-25T18:30:00Z" + } +} +``` + +Signiert vom Signing Service, Attestations-Hash wandert zurück in die Tabellen `per_diem_rates.rate_change_attestation_ref` und `per_diem_rate_changes.rate_change_attestation_ref`. diff --git a/services/admin-api/admin-api b/services/admin-api/admin-api new file mode 100644 index 0000000..e422f70 Binary files /dev/null and b/services/admin-api/admin-api differ diff --git a/services/admin-api/cmd/admin-api/main.go b/services/admin-api/cmd/admin-api/main.go new file mode 100644 index 0000000..7593521 --- /dev/null +++ b/services/admin-api/cmd/admin-api/main.go @@ -0,0 +1,86 @@ +// Command admin-api is the HTTP entry point for the per-diem-rates admin +// surface and future admin flows. Current state: skeleton. Pocket-ID auth +// middleware and the Signing-Service wiring are intentionally not here yet; +// the handlers assume a WithPrincipal context, which the real middleware will +// populate from a verified session cookie. +package main + +import ( + "context" + "flag" + "fmt" + "html/template" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/httpapi" + "basicstack.de/proofedmesh/services/admin-api/internal/store" +) + +func main() { + listen := flag.String("listen", ":8080", "HTTP listen address") + backend := flag.String("store", "memory", "storage backend: memory | postgres") + flag.Parse() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + var s store.RateStore + switch *backend { + case "memory": + logger.Info("admin-api: using in-memory store") + s = store.NewMemory() + case "postgres": + pg, err := store.NewPostgres() + if err != nil { + logger.Error("postgres store init failed", "err", err) + os.Exit(1) + } + s = pg + default: + fmt.Fprintln(os.Stderr, "unknown -store flag; expected memory|postgres") + os.Exit(2) + } + + deps := httpapi.Dependencies{ + Store: s, + Templates: template.New("admin"), // real templates loaded from web/templates in the full build + Now: time.Now, + } + + mux := http.NewServeMux() + httpapi.Mount(mux, deps) + + // Admin-only auth middleware is a stub that attaches a hardcoded principal + // in the skeleton. Production wiring will replace this with a Pocket ID + // session-cookie verifier that pulls `sub` from a validated ID token and + // enforces group membership in `proofedmesh-admin`. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := httpapi.WithPrincipal(r.Context(), "stub-admin") + mux.ServeHTTP(w, r.WithContext(ctx)) + }) + + srv := &http.Server{ + Addr: *listen, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + logger.Info("admin-api listening", "addr", *listen, "store", *backend) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("http serve", "err", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + logger.Info("admin-api shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) +} diff --git a/services/admin-api/go.mod b/services/admin-api/go.mod new file mode 100644 index 0000000..c2a48de --- /dev/null +++ b/services/admin-api/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/admin-api + +go 1.23 diff --git a/services/admin-api/internal/attest/ratechange.go b/services/admin-api/internal/attest/ratechange.go new file mode 100644 index 0000000..aa17e0a --- /dev/null +++ b/services/admin-api/internal/attest/ratechange.go @@ -0,0 +1,49 @@ +// Package attest hands off a rate-change event to the Signing Service and +// returns the SHA-256 of the signed attestation envelope. Current state: +// Stub — PERSUB will be wired in Phase-2. +package attest + +import ( + "context" + "errors" + + "basicstack.de/proofedmesh/services/admin-api/internal/model" +) + +type ChangeKind string + +const ( + ChangeCreate ChangeKind = "create" + ChangeRetire ChangeKind = "retire" +) + +// RateChangeEvent is what we hand to the Signing Service. +type RateChangeEvent struct { + Kind ChangeKind + Rate model.Rate + PerformedBy PerformedBy + PerformedAtRFC3339 string +} + +type PerformedBy struct { + Issuer string + Subject string + SessionID string +} + +// Signer issues a signed rate_change attestation and returns the content hash +// (sha256:...) under which it is stored in the Transparency Log. +type Signer interface { + SignRateChange(ctx context.Context, ev RateChangeEvent) (attestationRef string, err error) +} + +// NopSigner is a placeholder that lets the admin-api start without a real +// Signing Service. It must not be used in production — the HTTP layer gates +// its usage behind a startup flag. +type NopSigner struct{} + +var errNopNotForProd = errors.New("attest.NopSigner is a stub and must not be used in production") + +func (NopSigner) SignRateChange(ctx context.Context, ev RateChangeEvent) (string, error) { + return "", errNopNotForProd +} diff --git a/services/admin-api/internal/httpapi/rates.go b/services/admin-api/internal/httpapi/rates.go new file mode 100644 index 0000000..bb6da5d --- /dev/null +++ b/services/admin-api/internal/httpapi/rates.go @@ -0,0 +1,218 @@ +// 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" +} diff --git a/services/admin-api/internal/httpapi/rates_test.go b/services/admin-api/internal/httpapi/rates_test.go new file mode 100644 index 0000000..a8caee8 --- /dev/null +++ b/services/admin-api/internal/httpapi/rates_test.go @@ -0,0 +1,182 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/store" +) + +func newDeps(t *testing.T) Dependencies { + t.Helper() + return Dependencies{ + Store: store.NewMemory(), + Now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) }, + } +} + +func newMux(deps Dependencies) http.Handler { + mux := http.NewServeMux() + Mount(mux, deps) + // inject a principal for tests + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := WithPrincipal(r.Context(), "test-admin") + mux.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func TestList_Empty(t *testing.T) { + srv := httptest.NewServer(newMux(newDeps(t))) + defer srv.Close() + resp, err := http.Get(srv.URL + "/admin/per-diem-rates/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestCreateAndGet_JSON(t *testing.T) { + deps := newDeps(t) + srv := httptest.NewServer(newMux(deps)) + defer srv.Close() + body := `{ + "country":"DE","currency":"EUR", + "full_day":"28.00","partial_day_min8h":"14.00", + "arrival_day":"14.00","departure_day":"14.00", + "valid_from":"2026-01-01T00:00:00Z", + "source_regulation":"BMF 2026" + }` + req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("expected 201, got %d", resp.StatusCode) + } + var out map[string]map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + rateID, _ := out["rate"]["rate_id"].(string) + if rateID == "" { + t.Fatalf("no rate_id in response: %v", out) + } + + // Fetch it back + resp2, err := http.Get(srv.URL + "/admin/per-diem-rates/" + rateID) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Fatalf("get status %d", resp2.StatusCode) + } +} + +func TestCreate_InvalidBody_Rejected(t *testing.T) { + srv := httptest.NewServer(newMux(newDeps(t))) + defer srv.Close() + body := `{"country":"XYZ","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}` + req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestGet_NotFound(t *testing.T) { + srv := httptest.NewServer(newMux(newDeps(t))) + defer srv.Close() + resp, err := http.Get(srv.URL + "/admin/per-diem-rates/PDR-999") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } +} + +func TestRetire_UpdatesValidTo(t *testing.T) { + deps := newDeps(t) + srv := httptest.NewServer(newMux(deps)) + defer srv.Close() + + createBody := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}` + req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(createBody)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out map[string]map[string]any + _ = json.NewDecoder(resp.Body).Decode(&out) + rateID := out["rate"]["rate_id"].(string) + + retireBody := `{"valid_to":"2026-06-30"}` + req2, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/"+rateID+"/retire", strings.NewReader(retireBody)) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Fatalf("retire: expected 200, got %d", resp2.StatusCode) + } + var out2 map[string]map[string]any + _ = json.NewDecoder(resp2.Body).Decode(&out2) + if _, ok := out2["rate"]["valid_to"]; !ok { + t.Fatal("expected valid_to to be set after retire") + } +} + +func TestOverlap_Rejected(t *testing.T) { + srv := httptest.NewServer(newMux(newDeps(t))) + defer srv.Close() + + body := func(validFrom string, validTo *string) string { + base := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"` + validFrom + `T00:00:00Z","source_regulation":"BMF 2026"` + if validTo != nil { + base += `,"valid_to":"` + *validTo + `T00:00:00Z"` + } + return base + "}" + } + + vt := "2026-12-31" + req1, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body("2026-01-01", &vt))) + req1.Header.Set("Content-Type", "application/json") + resp1, err := http.DefaultClient.Do(req1) + if err != nil { + t.Fatal(err) + } + defer resp1.Body.Close() + if resp1.StatusCode != http.StatusCreated { + t.Fatalf("first create expected 201, got %d", resp1.StatusCode) + } + + req2, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body("2026-12-31", nil))) + req2.Header.Set("Content-Type", "application/json") + resp2, err := http.DefaultClient.Do(req2) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusConflict { + t.Fatalf("overlapping create: expected 409, got %d", resp2.StatusCode) + } +} diff --git a/services/admin-api/internal/model/rate.go b/services/admin-api/internal/model/rate.go new file mode 100644 index 0000000..78b782a --- /dev/null +++ b/services/admin-api/internal/model/rate.go @@ -0,0 +1,98 @@ +// Package model defines the Rate value type and its invariants, shared by the +// HTTP layer and the storage layer. All amounts are decimal strings matching +// the "^-?[0-9]+\\.[0-9]{2}$" pattern used across ProofedMesh schemas; we +// deliberately do not reach for float. +package model + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +var ( + countryPattern = regexp.MustCompile(`^[A-Z]{2}$`) + currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) + amountPattern = regexp.MustCompile(`^[0-9]+\.[0-9]{2}$`) + sha256Pattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +) + +// Rate mirrors a row in per_diem_rates. Monetary fields are decimal strings +// e.g. "28.00". Core business fields are set at creation and are immutable +// afterwards (enforced both by the Postgres trigger and by the admin API). +type Rate struct { + RateID string `json:"rate_id"` + Country string `json:"country"` // ISO 3166-1 alpha-2 + City *string `json:"city,omitempty"` // optional + Currency string `json:"currency"` // ISO 4217 + FullDay string `json:"full_day"` + PartialDayMin8h string `json:"partial_day_min8h"` + ArrivalDay string `json:"arrival_day"` + DepartureDay string `json:"departure_day"` + ValidFrom time.Time `json:"valid_from"` + ValidTo *time.Time `json:"valid_to,omitempty"` + SourceRegulation string `json:"source_regulation"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + RateChangeAttestationRef *string `json:"rate_change_attestation_ref,omitempty"` +} + +// NewRateInput carries the fields a caller may supply when creating a new rate. +// RateID, CreatedBy, CreatedAt and the attestation ref are assigned by the API, +// not the caller. +type NewRateInput struct { + Country string `json:"country"` + City *string `json:"city,omitempty"` + Currency string `json:"currency"` + FullDay string `json:"full_day"` + PartialDayMin8h string `json:"partial_day_min8h"` + ArrivalDay string `json:"arrival_day"` + DepartureDay string `json:"departure_day"` + ValidFrom time.Time `json:"valid_from"` + ValidTo *time.Time `json:"valid_to,omitempty"` + SourceRegulation string `json:"source_regulation"` +} + +// Validate checks the invariants that are enforced by the schema and that we +// want to fail-fast on in the API layer so the user sees a sensible error +// before we hit Postgres. +func (in NewRateInput) Validate() error { + if !countryPattern.MatchString(in.Country) { + return fmt.Errorf("country %q must be ISO 3166-1 alpha-2 (two uppercase letters)", in.Country) + } + if in.City != nil && (*in.City == "" || len(*in.City) > 100) { + return errors.New("city, if set, must be 1..100 characters") + } + if !currencyPattern.MatchString(in.Currency) { + return fmt.Errorf("currency %q must be ISO 4217 (three uppercase letters)", in.Currency) + } + for _, p := range []struct { + name string + value string + }{ + {"full_day", in.FullDay}, + {"partial_day_min8h", in.PartialDayMin8h}, + {"arrival_day", in.ArrivalDay}, + {"departure_day", in.DepartureDay}, + } { + if !amountPattern.MatchString(p.value) { + return fmt.Errorf("%s %q must be a decimal string with two decimals (e.g. '28.00')", p.name, p.value) + } + } + if in.ValidFrom.IsZero() { + return errors.New("valid_from is required") + } + if in.ValidTo != nil && in.ValidTo.Before(in.ValidFrom) { + return errors.New("valid_to must not be before valid_from") + } + if l := len(in.SourceRegulation); l < 1 || l > 500 { + return errors.New("source_regulation must be 1..500 characters") + } + return nil +} + +// IsAttestationRef returns true for a well-formed sha256: content hash. +func IsAttestationRef(ref string) bool { + return sha256Pattern.MatchString(ref) +} diff --git a/services/admin-api/internal/store/memory.go b/services/admin-api/internal/store/memory.go new file mode 100644 index 0000000..b8fc6c5 --- /dev/null +++ b/services/admin-api/internal/store/memory.go @@ -0,0 +1,148 @@ +package store + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/model" +) + +// Memory is a thread-safe in-memory implementation of RateStore. It is the +// fastest path for tests and the dev loop (`-store=memory`). Production uses +// the Postgres implementation. +type Memory struct { + mu sync.Mutex + rates map[string]model.Rate + seq int +} + +func NewMemory() *Memory { + return &Memory{rates: make(map[string]model.Rate)} +} + +func (m *Memory) List(ctx context.Context, f ListFilter) ([]model.Rate, error) { + m.mu.Lock() + defer m.mu.Unlock() + today := time.Now().UTC().Truncate(24 * time.Hour) + out := make([]model.Rate, 0, len(m.rates)) + for _, r := range m.rates { + if f.Country != "" && r.Country != f.Country { + continue + } + if f.ActiveOnly { + if r.ValidTo != nil && r.ValidTo.Before(today) { + continue + } + } + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].RateID < out[j].RateID }) + return out, nil +} + +func (m *Memory) Get(ctx context.Context, rateID string) (model.Rate, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.rates[rateID] + if !ok { + return model.Rate{}, ErrNotFound + } + return r, nil +} + +func (m *Memory) Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) { + m.mu.Lock() + defer m.mu.Unlock() + if err := checkOverlap(m.rates, in); err != nil { + return model.Rate{}, err + } + m.seq++ + r := model.Rate{ + RateID: fmt.Sprintf("PDR-%d", m.seq), + Country: in.Country, + City: in.City, + Currency: in.Currency, + FullDay: in.FullDay, + PartialDayMin8h: in.PartialDayMin8h, + ArrivalDay: in.ArrivalDay, + DepartureDay: in.DepartureDay, + ValidFrom: in.ValidFrom, + ValidTo: in.ValidTo, + SourceRegulation: in.SourceRegulation, + CreatedBy: createdBy, + CreatedAt: now, + } + m.rates[r.RateID] = r + return r, nil +} + +func (m *Memory) Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.rates[rateID] + if !ok { + return model.Rate{}, ErrNotFound + } + today := now.UTC().Truncate(24 * time.Hour) + if r.ValidTo != nil && r.ValidTo.Before(today) { + return model.Rate{}, ErrAlreadyRetired + } + if validTo.Before(r.ValidFrom) { + return model.Rate{}, fmt.Errorf("retire: valid_to %s is before valid_from %s", validTo.Format("2006-01-02"), r.ValidFrom.Format("2006-01-02")) + } + cpy := r + cpy.ValidTo = &validTo + m.rates[rateID] = cpy + return cpy, nil +} + +func (m *Memory) AttachAttestation(ctx context.Context, rateID string, attestationRef string) error { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.rates[rateID] + if !ok { + return ErrNotFound + } + if !model.IsAttestationRef(attestationRef) { + return fmt.Errorf("attestation ref %q is not a sha256: content hash", attestationRef) + } + cpy := r + cpy.RateChangeAttestationRef = &attestationRef + m.rates[rateID] = cpy + return nil +} + +func checkOverlap(existing map[string]model.Rate, in model.NewRateInput) error { + cityKey := "" + if in.City != nil { + cityKey = *in.City + } + newEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) + if in.ValidTo != nil { + newEnd = *in.ValidTo + } + for _, r := range existing { + if r.Country != in.Country { + continue + } + rc := "" + if r.City != nil { + rc = *r.City + } + if rc != cityKey { + continue + } + rEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC) + if r.ValidTo != nil { + rEnd = *r.ValidTo + } + // Overlapping ranges? Inclusive on both ends. + if !in.ValidFrom.After(rEnd) && !r.ValidFrom.After(newEnd) { + return ErrOverlap + } + } + return nil +} diff --git a/services/admin-api/internal/store/memory_test.go b/services/admin-api/internal/store/memory_test.go new file mode 100644 index 0000000..3a14f5b --- /dev/null +++ b/services/admin-api/internal/store/memory_test.go @@ -0,0 +1,138 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/model" +) + +func d(s string) time.Time { + t, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return t +} + +func rateDE(validFrom, validTo string) model.NewRateInput { + in := model.NewRateInput{ + Country: "DE", Currency: "EUR", + FullDay: "28.00", PartialDayMin8h: "14.00", + ArrivalDay: "14.00", DepartureDay: "14.00", + ValidFrom: d(validFrom), + SourceRegulation: "test", + } + if validTo != "" { + t := d(validTo) + in.ValidTo = &t + } + return in +} + +func TestMemory_CreateAndGet(t *testing.T) { + m := NewMemory() + ctx := context.Background() + r, err := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + if err != nil { + t.Fatalf("create: %v", err) + } + if r.RateID != "PDR-1" { + t.Errorf("expected PDR-1, got %s", r.RateID) + } + got, err := m.Get(ctx, "PDR-1") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.FullDay != "28.00" { + t.Errorf("roundtrip full_day: %s", got.FullDay) + } +} + +func TestMemory_Get_NotFound(t *testing.T) { + m := NewMemory() + _, err := m.Get(context.Background(), "PDR-999") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestMemory_OverlapRejected(t *testing.T) { + m := NewMemory() + ctx := context.Background() + if _, err := m.Create(ctx, rateDE("2026-01-01", "2026-12-31"), "admin", time.Now()); err != nil { + t.Fatalf("first create: %v", err) + } + // Overlap on the last day of 2026 + _, err := m.Create(ctx, rateDE("2026-12-31", ""), "admin", time.Now()) + if !errors.Is(err, ErrOverlap) { + t.Fatalf("expected ErrOverlap, got %v", err) + } +} + +func TestMemory_RetireChangesValidTo(t *testing.T) { + m := NewMemory() + ctx := context.Background() + r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + updated, err := m.Retire(ctx, r.RateID, d("2026-06-30"), "admin2", time.Now()) + if err != nil { + t.Fatalf("retire: %v", err) + } + if updated.ValidTo == nil || !updated.ValidTo.Equal(d("2026-06-30")) { + t.Errorf("valid_to not set to 2026-06-30: %v", updated.ValidTo) + } +} + +func TestMemory_Retire_BeforeValidFrom_Rejected(t *testing.T) { + m := NewMemory() + ctx := context.Background() + r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + _, err := m.Retire(ctx, r.RateID, d("2025-12-01"), "admin2", time.Now()) + if err == nil { + t.Fatal("expected error for valid_to before valid_from") + } +} + +func TestMemory_AttachAttestation(t *testing.T) { + m := NewMemory() + ctx := context.Background() + r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + err := m.AttachAttestation(ctx, r.RateID, "sha256:0000000000000000000000000000000000000000000000000000000000000001") + if err != nil { + t.Fatalf("attach: %v", err) + } + got, _ := m.Get(ctx, r.RateID) + if got.RateChangeAttestationRef == nil { + t.Fatal("expected attestation ref to be set") + } +} + +func TestMemory_AttachAttestation_BadRef_Rejected(t *testing.T) { + m := NewMemory() + ctx := context.Background() + r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + err := m.AttachAttestation(ctx, r.RateID, "not-a-hash") + if err == nil { + t.Fatal("expected error for malformed attestation ref") + } +} + +func TestMemory_List_FilterByCountry(t *testing.T) { + m := NewMemory() + ctx := context.Background() + _, _ = m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now()) + fr := rateDE("2026-01-01", "") + fr.Country = "FR" + _, _ = m.Create(ctx, fr, "admin", time.Now()) + + all, _ := m.List(ctx, ListFilter{}) + if len(all) != 2 { + t.Fatalf("expected 2 total, got %d", len(all)) + } + de, _ := m.List(ctx, ListFilter{Country: "DE"}) + if len(de) != 1 || de[0].Country != "DE" { + t.Fatalf("country filter failed: %+v", de) + } +} diff --git a/services/admin-api/internal/store/postgres.go b/services/admin-api/internal/store/postgres.go new file mode 100644 index 0000000..426d3b6 --- /dev/null +++ b/services/admin-api/internal/store/postgres.go @@ -0,0 +1,39 @@ +package store + +import ( + "context" + "errors" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/model" +) + +// Postgres is the production RateStore. **Skeleton:** methods return +// errPGUnimplemented. When wiring, swap this struct's internals for a +// pgxpool.Pool and real queries. The SQL is already in db/trust/migrations/ +// and the schema enforces every invariant that Memory enforces. +type Postgres struct { + // pool *pgxpool.Pool // kept out of go.mod in the skeleton +} + +var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for now") + +func NewPostgres( /* conn string, logger */ ) (*Postgres, error) { + return &Postgres{}, nil +} + +func (p *Postgres) List(ctx context.Context, f ListFilter) ([]model.Rate, error) { + return nil, errPGUnimplemented +} +func (p *Postgres) Get(ctx context.Context, rateID string) (model.Rate, error) { + return model.Rate{}, errPGUnimplemented +} +func (p *Postgres) Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) { + return model.Rate{}, errPGUnimplemented +} +func (p *Postgres) Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) { + return model.Rate{}, errPGUnimplemented +} +func (p *Postgres) AttachAttestation(ctx context.Context, rateID string, attestationRef string) error { + return errPGUnimplemented +} diff --git a/services/admin-api/internal/store/store.go b/services/admin-api/internal/store/store.go new file mode 100644 index 0000000..d15aa05 --- /dev/null +++ b/services/admin-api/internal/store/store.go @@ -0,0 +1,50 @@ +// Package store abstracts the per_diem_rates storage so the HTTP layer can be +// tested without a real Postgres and so the Postgres backend can be swapped +// against an in-memory fake for dev loops. +package store + +import ( + "context" + "errors" + "time" + + "basicstack.de/proofedmesh/services/admin-api/internal/model" +) + +// ErrNotFound is returned by Get and Retire when the rate_id does not exist. +var ErrNotFound = errors.New("per_diem_rate: not found") + +// ErrOverlap is returned by Create when the new rate would overlap an existing +// (country, city) range. Mirrors the Postgres EXCLUDE constraint. +var ErrOverlap = errors.New("per_diem_rate: overlaps an existing active range for (country, city)") + +// ErrAlreadyRetired is returned by Retire when the rate is already retired +// (valid_to IS NOT NULL and in the past). +var ErrAlreadyRetired = errors.New("per_diem_rate: already retired") + +// ListFilter narrows a List call. Zero value lists everything. +type ListFilter struct { + Country string // "" = any + ActiveOnly bool // if true, only rates with valid_to IS NULL or valid_to >= today +} + +// RateStore is implemented by both the in-memory store (for tests + dev) and +// the Postgres store (for prod). All methods are side-effect-free apart from +// their obvious effect and all honour ctx cancellation. +type RateStore interface { + List(ctx context.Context, f ListFilter) ([]model.Rate, error) + Get(ctx context.Context, rateID string) (model.Rate, error) + + // Create inserts a new rate, returning the finalised row (rate_id is + // assigned by the backend). The attestation ref is set later via + // AttachAttestation when the Signing Service has produced it. + Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) + + // Retire sets valid_to on the rate; subsequent lookups by valid_on past + // that date must not return it. + Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) + + // AttachAttestation attaches the signed attestation reference to a rate. + // Called after Create or Retire once the Signing Service responded. + AttachAttestation(ctx context.Context, rateID string, attestationRef string) error +} diff --git a/services/admin-api/web/templates/per_diem_rates_detail.html b/services/admin-api/web/templates/per_diem_rates_detail.html new file mode 100644 index 0000000..5333676 --- /dev/null +++ b/services/admin-api/web/templates/per_diem_rates_detail.html @@ -0,0 +1,41 @@ + + + + + Rate {{.Rate.RateID}} + + + +

← Zurück zur Liste

+

{{.Rate.RateID}}

+
+
Land / Stadt
{{.Rate.Country}}{{if .Rate.City}} / {{.Rate.City}}{{end}}
+
Währung
{{.Rate.Currency}}
+
Voller Tag
{{.Rate.FullDay}}
+
8h+ Teiltag
{{.Rate.PartialDayMin8h}}
+
Anreisetag
{{.Rate.ArrivalDay}}
+
Abreisetag
{{.Rate.DepartureDay}}
+
Gültig ab
{{.Rate.ValidFrom.Format "2006-01-02"}}
+
Gültig bis
{{if .Rate.ValidTo}}{{.Rate.ValidTo.Format "2006-01-02"}}{{else}}—{{end}}
+
Quelle
{{.Rate.SourceRegulation}}
+
Angelegt von
{{.Rate.CreatedBy}} am {{.Rate.CreatedAt.Format "2006-01-02 15:04 UTC"}}
+
Attestations-Ref
{{if .Rate.RateChangeAttestationRef}}{{.Rate.RateChangeAttestationRef}}{{else}}(ausstehend){{end}}
+
+ + {{if not .Rate.ValidTo}} +

Rate ablaufen lassen

+
+ + +
+

Hinweis: Die Rate bleibt für historische Berechnungen bestehen; nur neu generierte Workflows nach valid_to nutzen eine Nachfolge-Rate.

+ {{else}} +

Rate ist bereits bis {{.Rate.ValidTo.Format "2006-01-02"}} begrenzt — keine weitere Pflegeaktion möglich.

+ {{end}} + + diff --git a/services/admin-api/web/templates/per_diem_rates_list.html b/services/admin-api/web/templates/per_diem_rates_list.html new file mode 100644 index 0000000..c8d2421 --- /dev/null +++ b/services/admin-api/web/templates/per_diem_rates_list.html @@ -0,0 +1,95 @@ + + + + + Per-Diem-Pauschalen + + + + +

Per-Diem-Pauschalen

+ +
+ + + +
+ + + + + + + + + + + + + + + + + + {{range .Rates}} + + + + + + + + + + + + + {{else}} + + {{end}} + +
RateLand / StadtWährungVoll8h+AnreiseAbreiseGültig abGültig bisQuelle
{{.RateID}}{{.Country}}{{if .City}} / {{.City}}{{end}}{{.Currency}}{{.FullDay}}{{.PartialDayMin8h}}{{.ArrivalDay}}{{.DepartureDay}}{{.ValidFrom.Format "2006-01-02"}}{{if .ValidTo}}{{.ValidTo.Format "2006-01-02"}}{{else}}—{{end}}{{.SourceRegulation}}
Keine Rates gefunden.
+ +

Neue Rate anlegen

+
+

+ + + +

+

+ + + + +

+

+ + +

+

+ +

+ +
+ +