feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- GET /qr: Czech QR Platba PNG; ports Python qr_code() exactly
(account validation, amount clamping, * stripping, SPD format)
- GET /sync-bank: Fio sync → infer → cache flush with captured log
- GET+POST /flush-cache: form + action, shows deleted count
- GET /version: JSON alias of /api/version (Python parity)
- FlushCache() added to membership.Sources; wired through api.Handler
- web.ActionHandlers{BankSync} closure-based dep injection for sync
- New dep: github.com/skip2/go-qrcode
- TestQRBuildSPD (9 cases), TestServeQR, TestServeFlushCache{GET,POST},
TestServeSync, TestServeVersion added
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,9 @@ type Handler struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// FlushCache invalidates all cached data via the underlying Sources.
|
||||
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
||||
|
||||
// ServeVersion handles GET /api/version.
|
||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, VersionResponse{
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// HTMLHandler serves the Go-native HTML frontend.
|
||||
@@ -10,11 +13,12 @@ type HTMLHandler struct {
|
||||
renderer *Renderer
|
||||
build BuildInfo
|
||||
apiHandler *api.Handler
|
||||
actions ActionHandlers
|
||||
}
|
||||
|
||||
// NewHTMLHandler constructs an HTMLHandler.
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
||||
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
|
||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -62,10 +66,78 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
||||
pd := PageData{Active: "sync", Build: h.build}
|
||||
|
||||
if h.actions.BankSync == nil {
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: pd,
|
||||
Output: "Bank sync is not configured.",
|
||||
Success: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
success := true
|
||||
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
|
||||
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
|
||||
success = false
|
||||
}
|
||||
|
||||
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
|
||||
n, err := h.apiHandler.FlushCache()
|
||||
if err != nil {
|
||||
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
|
||||
success = false
|
||||
} else {
|
||||
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
|
||||
}
|
||||
|
||||
h.renderer.Render(w, "sync", SyncPageData{
|
||||
PageData: pd,
|
||||
Output: buf.String(),
|
||||
Success: success,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
||||
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: PageData{Active: "flush", Build: h.build},
|
||||
})
|
||||
}
|
||||
|
||||
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
|
||||
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||
n, _ := h.apiHandler.FlushCache()
|
||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||
PageData: PageData{Active: "flush", Build: h.build},
|
||||
Flushed: true,
|
||||
Deleted: n,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
|
||||
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
account := q.Get("account")
|
||||
amount := q.Get("amount")
|
||||
message := q.Get("message")
|
||||
if account == "" {
|
||||
account = h.apiHandler.Config.BankAccount
|
||||
}
|
||||
if amount == "" {
|
||||
amount = "0"
|
||||
}
|
||||
|
||||
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
|
||||
png, err := RenderQRCode(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(png)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/domain/reconcile"
|
||||
"fuj-management/go/internal/web"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -41,6 +44,8 @@ func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.Exception
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fixtureSources) FlushCache() (int, error) { return 0, nil }
|
||||
|
||||
func fixtureHandler(t *testing.T) *api.Handler {
|
||||
t.Helper()
|
||||
return &api.Handler{
|
||||
@@ -55,7 +60,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
@@ -65,7 +70,7 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
||||
{"/juniors", h.ServeJuniors},
|
||||
{"/payments", h.ServePayments},
|
||||
{"/sync-bank", h.ServeSync},
|
||||
{"/flush-cache", h.ServeFlushCache},
|
||||
{"/flush-cache", h.ServeFlushCacheGET},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -99,7 +104,7 @@ func TestAdultsPage(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -133,7 +138,7 @@ func TestModalMarkup(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
@@ -174,7 +179,7 @@ func TestPaymentsPage(t *testing.T) {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -198,3 +203,135 @@ func TestPaymentsPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeQR(t *testing.T) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeQR(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
|
||||
t.Errorf("Content-Type = %q, want image/png", ct)
|
||||
}
|
||||
// PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
body := w.Body.Bytes()
|
||||
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
|
||||
t.Error("response body does not start with PNG magic bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCacheGET(t *testing.T) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCacheGET(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Flush Cache") {
|
||||
t.Error("body missing Flush Cache heading")
|
||||
}
|
||||
if strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("GET should not show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeFlushCachePOST(t *testing.T) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), web.ActionHandlers{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeFlushCachePOST(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
// fixtureSources.FlushCache returns 0
|
||||
if !strings.Contains(body, "file(s) deleted") {
|
||||
t.Error("POST should show deleted count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeSync(t *testing.T) {
|
||||
renderer, err := web.NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(_ context.Context, out io.Writer) error {
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
fmt.Fprintln(out, "Synced 3 new transaction(s).")
|
||||
fmt.Fprintln(out, "=== Infer Payments ===")
|
||||
fmt.Fprintln(out, "Inferred 2 row(s).")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeSync(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Sync Bank Data",
|
||||
"Synced 3 new transaction(s).",
|
||||
"Inferred 2 row(s).",
|
||||
"Flush Cache",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeVersion(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
|
||||
w := httptest.NewRecorder()
|
||||
fixtureHandler(t).ServeVersion(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
|
||||
t.Fatalf("decode JSON: %v", err)
|
||||
}
|
||||
for _, key := range []string{"tag", "commit", "build_date"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Errorf("JSON response missing key %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
50
go/internal/web/qr.go
Normal file
50
go/internal/web/qr.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
|
||||
|
||||
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
|
||||
// Invalid account falls back to defaultAccount.
|
||||
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
|
||||
// Message is truncated to 60 runes and stripped of '*' characters.
|
||||
func BuildSPD(account, amount, message, defaultAccount string) string {
|
||||
if !validAccount.MatchString(account) {
|
||||
account = defaultAccount
|
||||
}
|
||||
|
||||
var amtStr string
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
|
||||
amtStr = "0.00"
|
||||
} else {
|
||||
amtStr = fmt.Sprintf("%.2f", f)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(message) > 60 {
|
||||
runes := []rune(message)
|
||||
message = string(runes[:60])
|
||||
}
|
||||
message = strings.ReplaceAll(message, "*", "")
|
||||
|
||||
var accStr string
|
||||
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
|
||||
accStr = parts[0] + "*BC:" + parts[1]
|
||||
} else {
|
||||
accStr = account
|
||||
}
|
||||
|
||||
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
|
||||
}
|
||||
|
||||
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
|
||||
func RenderQRCode(payload string) ([]byte, error) {
|
||||
return qrcode.Encode(payload, qrcode.Medium, 256)
|
||||
}
|
||||
91
go/internal/web/qr_test.go
Normal file
91
go/internal/web/qr_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQRBuildSPD(t *testing.T) {
|
||||
const def = "2702008874/2010"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
account string
|
||||
amount string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "czech account",
|
||||
account: "2702008874/2010",
|
||||
amount: "700",
|
||||
message: "Test Member: 01/2026",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
|
||||
},
|
||||
{
|
||||
name: "IBAN account",
|
||||
account: "CZ6508000000192000145399",
|
||||
amount: "500",
|
||||
message: "hi",
|
||||
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
|
||||
},
|
||||
{
|
||||
name: "invalid account falls back to default",
|
||||
account: "NOTANACCOUNT",
|
||||
amount: "100",
|
||||
message: "x",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
|
||||
},
|
||||
{
|
||||
name: "empty account falls back to default",
|
||||
account: "",
|
||||
amount: "0",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "negative amount clamped to 0.00",
|
||||
account: def,
|
||||
amount: "-1",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "amount over 10M clamped to 0.00",
|
||||
account: def,
|
||||
amount: "99999999",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "non-numeric amount becomes 0.00",
|
||||
account: def,
|
||||
amount: "abc",
|
||||
message: "",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
|
||||
},
|
||||
{
|
||||
name: "asterisks stripped from message",
|
||||
account: def,
|
||||
amount: "100",
|
||||
message: "pay*now",
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
|
||||
},
|
||||
{
|
||||
name: "message truncated to 60 runes",
|
||||
account: def,
|
||||
amount: "0",
|
||||
message: strings.Repeat("á", 65),
|
||||
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := BuildSPD(tc.account, tc.amount, tc.message, def)
|
||||
if got != tc.want {
|
||||
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,20 @@ type PaymentsPageData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
// SyncPageData is the view model for the /sync-bank HTML page.
|
||||
type SyncPageData struct {
|
||||
PageData
|
||||
Output string
|
||||
Success bool
|
||||
}
|
||||
|
||||
// FlushPageData is the view model for the /flush-cache HTML page.
|
||||
type FlushPageData struct {
|
||||
PageData
|
||||
Flushed bool
|
||||
Deleted int
|
||||
}
|
||||
|
||||
// Renderer parses and executes HTML templates from the embedded FS.
|
||||
type Renderer struct {
|
||||
tmpls map[string]*template.Template
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -18,8 +20,16 @@ type BuildInfo struct {
|
||||
BuildDate string
|
||||
}
|
||||
|
||||
// ActionHandlers holds function closures for side-effectful operations that
|
||||
// require dependencies (fio, sheets) not present on the core API handler.
|
||||
type ActionHandlers struct {
|
||||
// BankSync runs sync+infer and writes a human-readable log to out.
|
||||
// nil disables the /sync-bank action (renders an error instead).
|
||||
BankSync func(ctx context.Context, out io.Writer) error
|
||||
}
|
||||
|
||||
// Run registers routes and starts the HTTP server on addr.
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("init templates: %w", err)
|
||||
@@ -33,7 +43,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
}
|
||||
hh := NewHTMLHandler(renderer, build, ah)
|
||||
hh := NewHTMLHandler(renderer, build, ah, actions)
|
||||
|
||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
@@ -50,13 +60,16 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
||||
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
||||
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
|
||||
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCacheGET)
|
||||
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
|
||||
mux.HandleFunc("GET /qr", hh.ServeQR)
|
||||
|
||||
// Static files
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||
|
||||
// JSON API routes
|
||||
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||
mux.HandleFunc("GET /version", ah.ServeVersion)
|
||||
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{{define "title"}}Flush Cache{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Flush Cache</h1>
|
||||
<p class="description">Coming in M6.6</p>
|
||||
{{if .Flushed}}
|
||||
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
|
||||
{{end}}
|
||||
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
|
||||
<form method="POST" action="/flush-cache">
|
||||
<button type="submit" class="btn">Flush Cache</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{{define "title"}}Sync Bank Data{{end}}
|
||||
{{define "content"}}
|
||||
<h1>Sync Bank Data</h1>
|
||||
<p class="description">Coming in M6.6</p>
|
||||
{{if .Output}}
|
||||
{{if .Success}}
|
||||
<p class="status-ok">Sync completed successfully.</p>
|
||||
{{else}}
|
||||
<p class="status-error">Sync failed — see log below.</p>
|
||||
{{end}}
|
||||
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
||||
{{else}}
|
||||
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
|
||||
{{end}}
|
||||
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user