218 lines
6.5 KiB
Go
218 lines
6.5 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"
|
|
"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"
|
|
}
|