// 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" }