feat: Go rewrite M1 — skeleton, tooling, and hello server
Stand up the Go project alongside the Python backend so both run
independently during migration. `make web-go` builds and serves on :8080;
`make web-py` (alias: `make web`) keeps the Python side on :5001.
- go/: new module `fuj-management/go` (Go 1.26)
- cmd/fuj: stdlib-flag dispatcher; `server` + `version` work,
fees/reconcile/sync/infer stubbed for M2/M4
- internal/config: env loader mirroring scripts/config.py
- internal/logging: slog setup, level taken from config
- internal/web: net/http ServeMux + request-timer middleware
- build/Dockerfile: golang:1.26 → alpine:3 multi-stage image
- .golangci.yml: govet, staticcheck, errcheck, gofumpt, unused
- Makefile: web→web-py alias; go-build/go-test/go-run/go-lint/web-go
- CI: parallel build-go job in .gitea/workflows/build.yaml (<tag>-go image)
- docs/plans/: M1 kickoff plan + progress tracker (M1 complete)
- .claude/settings.json: gofumpt + golangci-lint permissions
Gate: make go-build ✓ make go-lint ✓ make go-test ✓ curl :8080 ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
16
.claude/settings.json
Normal file
16
.claude/settings.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(go version *)",
|
||||||
|
"Bash(go mod *)",
|
||||||
|
"Bash(golangci-lint run *)",
|
||||||
|
"Bash(golangci-lint --version)",
|
||||||
|
"Bash(gofumpt *)",
|
||||||
|
"Bash(./bin/fuj help *)",
|
||||||
|
"Bash(./bin/fuj version *)",
|
||||||
|
"Bash(make go-test *)",
|
||||||
|
"Bash(make go-lint *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,3 +37,29 @@ jobs:
|
|||||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-t $IMAGE .
|
-t $IMAGE .
|
||||||
docker push $IMAGE
|
docker push $IMAGE
|
||||||
|
|
||||||
|
build-go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
|
||||||
|
|
||||||
|
- name: Build and push Go image
|
||||||
|
run: |
|
||||||
|
TAG=${{ github.ref_name }}
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG=${{ inputs.tag }}
|
||||||
|
fi
|
||||||
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go
|
||||||
|
docker build -f go/build/Dockerfile \
|
||||||
|
--build-arg GIT_TAG=$TAG \
|
||||||
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
-t $IMAGE go/
|
||||||
|
docker push $IMAGE
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@
|
|||||||
|
|
||||||
# local tmp folder
|
# local tmp folder
|
||||||
tmp/
|
tmp/
|
||||||
|
|
||||||
|
# go build output
|
||||||
|
bin/
|
||||||
|
|||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-04 12:02 CEST — Go rewrite M1: skeleton + tooling
|
||||||
|
|
||||||
|
- Created `go/` tree with module `fuj-management/go` (Go 1.26).
|
||||||
|
- `cmd/fuj`: stdlib-flag subcommand dispatcher; `server` and `version` implemented, stubs for M2/M4 commands.
|
||||||
|
- `internal/config`: env loader mirroring `scripts/config.py` (same env var names and defaults).
|
||||||
|
- `internal/logging`: slog setup accepting log level from config.
|
||||||
|
- `internal/web`: `net/http` ServeMux on `:8080`; `middleware/timer.go` logs method/path/status/ms.
|
||||||
|
- `go/build/Dockerfile`: multi-stage (`golang:1.26` → `alpine:3`) producing a static binary image.
|
||||||
|
- Makefile: `web` → `web-py` alias; added `web-go`, `go-build`, `go-test`, `go-run`, `go-lint`.
|
||||||
|
- `.gitea/workflows/build.yaml`: parallel `build-go` job pushing `<tag>-go` image.
|
||||||
|
- Gate: `make go-build`, `make go-lint`, `make go-test`, `curl :8080` all pass.
|
||||||
|
|
||||||
## 2026-05-03 20:37 CEST — Fix Balance column to correctly reflect past-month debt
|
## 2026-05-03 20:37 CEST — Fix Balance column to correctly reflect past-month debt
|
||||||
|
|
||||||
- Balance (and Pay-All) are now computed as `sum(paid − expected)` over past months only, iterating directly over the ledger entries from `reconcile()`.
|
- Balance (and Pay-All) are now computed as `sum(paid − expected)` over past months only, iterating directly over the ledger entries from `reconcile()`.
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -108,3 +108,13 @@ Maintain a running changelog in `CHANGELOG.md` at the repo root. After every sig
|
|||||||
```
|
```
|
||||||
|
|
||||||
Get the timestamp with `date "+%Y-%m-%d %H:%M %Z"`. Skip trivial edits (typos, formatting, comment tweaks); only log changes a future reader would care about.
|
Get the timestamp with `date "+%Y-%m-%d %H:%M %Z"`. Skip trivial edits (typos, formatting, comment tweaks); only log changes a future reader would care about.
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
When Claude Code's plan mode is used, save the plan file inside the repo at
|
||||||
|
`docs/plans/YYYY-MM-DD-HHMM-<slug>.md` instead of the default `~/.claude/plans/`
|
||||||
|
location. Get the timestamp with `date "+%Y-%m-%d-%H%M"` (matches the changelog
|
||||||
|
convention). The `<slug>` should be a short kebab-case summary of the plan's topic.
|
||||||
|
|
||||||
|
Create the `docs/plans/` directory on first use. Plan files are committed to the
|
||||||
|
repo so other contributors can review historical decisions.
|
||||||
|
|||||||
45
Makefile
45
Makefile
@@ -1,10 +1,13 @@
|
|||||||
.PHONY: help fees match web web-debug image run sync sync-2026 test test-v docs
|
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-run go-lint image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PYTHON := $(VENV)/bin/python3
|
PYTHON := $(VENV)/bin/python3
|
||||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
||||||
|
|
||||||
|
GO_SRC := go
|
||||||
|
GO_BIN := bin/fuj
|
||||||
|
|
||||||
$(PYTHON): .venv/.last_sync
|
$(PYTHON): .venv/.last_sync
|
||||||
|
|
||||||
.venv/.last_sync: pyproject.toml
|
.venv/.last_sync: pyproject.toml
|
||||||
@@ -15,18 +18,23 @@ help:
|
|||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@echo " make match - Match Fio bank payments against expected attendance fees"
|
||||||
@echo " make web - Start a dynamic web dashboard locally"
|
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
|
||||||
@echo " make web-debug - Start a dynamic web dashboard locally in debug mode"
|
@echo " make web-py - Start Python dashboard on :5001"
|
||||||
@echo " make image - Build an OCI container image"
|
@echo " make web-go - Build and start Go dashboard on :8080"
|
||||||
@echo " make run - Run the built Docker image locally"
|
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||||
|
@echo " make go-build - Build Go binary to bin/fuj"
|
||||||
|
@echo " make go-test - Run Go tests"
|
||||||
|
@echo " make go-lint - Run golangci-lint on Go code"
|
||||||
|
@echo " make image - Build Python OCI container image"
|
||||||
|
@echo " make run - Run the built Python Docker image locally"
|
||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@echo " make venv - Sync virtual environment with pyproject.toml"
|
@echo " make venv - Sync virtual environment with pyproject.toml"
|
||||||
@echo " make test - Run web application infrastructure tests"
|
@echo " make test - Run Python web application infrastructure tests"
|
||||||
@echo " make test-v - Run tests with verbose output"
|
@echo " make test-v - Run Python tests with verbose output"
|
||||||
@echo " make docs - Serve documentation in a browser"
|
@echo " make docs - Serve documentation in a browser"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
@@ -38,12 +46,33 @@ fees: $(PYTHON)
|
|||||||
match: $(PYTHON)
|
match: $(PYTHON)
|
||||||
$(PYTHON) scripts/match_payments.py
|
$(PYTHON) scripts/match_payments.py
|
||||||
|
|
||||||
web: $(PYTHON)
|
web: web-py
|
||||||
|
|
||||||
|
web-py: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) app.py
|
||||||
|
|
||||||
web-debug: $(PYTHON)
|
web-debug: $(PYTHON)
|
||||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
FLASK_DEBUG=1 $(PYTHON) app.py
|
||||||
|
|
||||||
|
go-build:
|
||||||
|
cd $(GO_SRC) && go build -trimpath \
|
||||||
|
-ldflags "-X main.version=$$(git describe --tags --always 2>/dev/null || echo dev) \
|
||||||
|
-X main.commit=$$(git rev-parse --short HEAD) \
|
||||||
|
-X main.buildDate=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
-o ../$(GO_BIN) ./cmd/fuj
|
||||||
|
|
||||||
|
go-test:
|
||||||
|
cd $(GO_SRC) && go test -race ./...
|
||||||
|
|
||||||
|
go-run: go-build
|
||||||
|
./$(GO_BIN) $(ARGS)
|
||||||
|
|
||||||
|
go-lint:
|
||||||
|
cd $(GO_SRC) && golangci-lint run ./...
|
||||||
|
|
||||||
|
web-go: go-build
|
||||||
|
./$(GO_BIN) server
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t fuj-management:latest \
|
docker build -t fuj-management:latest \
|
||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Plan: Document plan-file location convention in `CLAUDE.md`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The user wants all plan files (created during Claude Code's plan mode) to live
|
||||||
|
inside the project at `docs/plans/`, with a creation timestamp in the filename.
|
||||||
|
This keeps planning artifacts version-controlled alongside the code, makes it
|
||||||
|
easy to see when each plan was drafted, and — critically — needs to be
|
||||||
|
discoverable by other contributors who use Claude Code on this repo. So the
|
||||||
|
convention belongs in `CLAUDE.md`, not in private agent memory.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Add a new section to `CLAUDE.md`** (placed near the existing "Changelog"
|
||||||
|
section, since both are about persisted artifacts that Claude maintains):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
When Claude Code's plan mode is used, save the plan file inside the repo at
|
||||||
|
`docs/plans/YYYY-MM-DD-HHMM-<slug>.md` instead of the default
|
||||||
|
`~/.claude/plans/` location. Get the timestamp with
|
||||||
|
`date "+%Y-%m-%d-%H%M"` (matches the changelog convention). The `<slug>`
|
||||||
|
should be a short kebab-case summary of the plan's topic.
|
||||||
|
|
||||||
|
Create the `docs/plans/` directory on first use. Plan files are committed
|
||||||
|
to the repo so other contributors can review historical decisions.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create the `docs/plans/` directory** with a `.gitkeep` (or just let it
|
||||||
|
appear when the first plan is moved in) so the path exists.
|
||||||
|
|
||||||
|
3. **Move this current plan** into the new location once plan mode exits:
|
||||||
|
`docs/plans/2026-05-03-1200-document-plan-location-convention.md`
|
||||||
|
(timestamp will be re-generated with the actual `date` output).
|
||||||
|
|
||||||
|
4. **No memory entry needed** — the rule lives in `CLAUDE.md` and is loaded
|
||||||
|
automatically into every Claude Code session in this repo.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- [CLAUDE.md](CLAUDE.md) — add the new "## Plans" section.
|
||||||
|
- New directory: [docs/plans/](docs/plans/) — created on first use.
|
||||||
|
- Move this plan file from `~/.claude/plans/...` into `docs/plans/` with the
|
||||||
|
proper timestamped filename.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `grep -A 5 "## Plans" CLAUDE.md` shows the new section.
|
||||||
|
- `ls docs/plans/` lists this plan file with a `YYYY-MM-DD-HHMM-` prefix.
|
||||||
|
- Next time plan mode is entered in this repo, the new plan is written to
|
||||||
|
`docs/plans/` with a fresh timestamp (verify by re-entering plan mode).
|
||||||
158
docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md
Normal file
158
docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Go Rewrite — Progress Tracker
|
||||||
|
|
||||||
|
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||||
|
|
||||||
|
**Current milestone:** M2 — Pure-domain helpers
|
||||||
|
**Started:** 2026-05-04
|
||||||
|
**Last updated:** 2026-05-04
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
- Tick a checkbox when the task's PR/commit lands. Append the SHA in the same
|
||||||
|
line: `[x] **M1.1** ... — `abc1234``.
|
||||||
|
- One task = one focused commit or PR. If a task balloons, split it and add
|
||||||
|
sub-tasks below the parent.
|
||||||
|
- Note decisions, surprises, or blockers under "Notes & decisions" at the
|
||||||
|
bottom — that's where future-you (or a contributor) will look first.
|
||||||
|
- Don't reorder milestones. Within a milestone, tasks can be done in any
|
||||||
|
order unless explicitly noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 — Skeleton + tooling
|
||||||
|
|
||||||
|
Goal: `make web-go` serves a hello page on :8080 in parallel with `make web-py` on :5001. Lint clean.
|
||||||
|
|
||||||
|
- [x] **M1.1** Create `go/` tree skeleton + `go.mod` initialized to latest stable Go
|
||||||
|
- [x] **M1.2** Add `cmd/fuj/main.go` with subcommand dispatcher — stdlib `flag` + `os.Args[1]` switch
|
||||||
|
- [x] **M1.3** Wire `fuj server` subcommand: `net/http` ServeMux on `:8080`, plaintext hello page
|
||||||
|
- [x] **M1.4** Add Makefile targets: `go-build`, `go-test`, `go-run`, `go-lint`
|
||||||
|
- [x] **M1.5** Rename existing `make web` → `make web-py`; added `make web-go`; kept `make web` as alias
|
||||||
|
- [x] **M1.6** Add `go/.golangci.yml` (govet, staticcheck, errcheck, gofumpt, unused) + `make go-lint` clean
|
||||||
|
- [x] **M1.7** Write `go/build/Dockerfile` (multi-stage `golang:1.26` → `alpine:3`); parallel `build-go` job in Gitea CI
|
||||||
|
- [x] **M1.8** Add `internal/config` package mirroring `scripts/config.py` (same env var names + defaults)
|
||||||
|
- [x] **M1.9** Add `internal/logging` (slog, level from config) + `middleware/timer.go` (method/path/status/ms)
|
||||||
|
- [x] **M1.10** Gate passed: `make go-build`, `make go-lint`, `make go-test`, `curl :8080` all green; CHANGELOG entry added
|
||||||
|
|
||||||
|
**Gate:** ✅ `make go-build` succeeds, `curl localhost:8080` returns hello page, `make go-lint` clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M2 — Pure-domain helpers (port leaf-first)
|
||||||
|
|
||||||
|
Goal: every pure function from the Python backend exists in Go with a parity test against captured fixtures (M3 produces fixtures in parallel — order is M2.1 → M3.1/M3.2 → M3.3+ alongside M2.2+).
|
||||||
|
|
||||||
|
Each task: port the function, write Go unit tests for fresh cases, hook into the Tier-1 parity runner.
|
||||||
|
|
||||||
|
- [ ] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase)
|
||||||
|
- [ ] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference)
|
||||||
|
- [ ] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table)
|
||||||
|
- [ ] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel
|
||||||
|
- [ ] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators)
|
||||||
|
- [ ] **M2.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows)
|
||||||
|
- [ ] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter)
|
||||||
|
- [ ] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing)
|
||||||
|
- [ ] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30)
|
||||||
|
- [ ] **M2.10** `domain/reconcile.Reconcile` — port `reconcile` (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time.
|
||||||
|
- [ ] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands
|
||||||
|
- [ ] **M2.12** `fuj reconcile` subcommand similarly stubbed
|
||||||
|
|
||||||
|
**Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M3 — Fixture capture + characterization framework
|
||||||
|
|
||||||
|
Goal: deterministic, PII-free fixture corpus that drives parity tests. Runs in parallel with M2 (M3.1/M3.2 unblocks M2.1).
|
||||||
|
|
||||||
|
- [ ] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON
|
||||||
|
- [ ] **M3.2** `scripts/scrub_fixtures.py` — replaces names with `Member_<8hex>` (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys
|
||||||
|
- [ ] **M3.3** Capture pure-fn fixtures for M2.1–M2.9 (run helper + scrubber, commit to `tests/fixtures/pure/<func>/<case>.json`)
|
||||||
|
- [ ] **M3.4** Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, `other:` purpose, junior `"?"`, multi-person comma-split, multi-month range, unmatched. Commit to `tests/fixtures/reconcile/`
|
||||||
|
- [ ] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint
|
||||||
|
- [ ] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes)
|
||||||
|
|
||||||
|
**Gate:** `tests/fixtures/` populated; M2 parity tests green; raw `tmp/*.json` confirmed gitignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M4 — IO layer behind interfaces
|
||||||
|
|
||||||
|
Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narrow Go interface with both a real and a fake implementation.
|
||||||
|
|
||||||
|
- [ ] **M4.1** Design IO interfaces (`SheetsClient`, `DriveClient`, `FioClient`, `FileCache`) + in-memory fakes seeded from M3 fixtures
|
||||||
|
- [ ] **M4.2** `internal/io/sheets` — Google client (read + append + batchUpdate); integration test against a separate test sheet (NOT prod)
|
||||||
|
- [ ] **M4.3** `internal/io/drive` — Drive `modifiedTime` client + integration test
|
||||||
|
- [ ] **M4.4** `internal/io/fio` — API JSON impl (token-based); parses by hardcoded `column0..column22` indices matching [fio_utils.py](scripts/fio_utils.py)
|
||||||
|
- [ ] **M4.5** `internal/io/fio` — transparent-page HTML scraper using `golang.org/x/net/html` token visitor; targets the **second** `<table class="table">`
|
||||||
|
- [ ] **M4.6** `internal/io/cache` — FileCache with `modifiedTime` gating + two TTL knobs + atomic writes (`os.Rename`)
|
||||||
|
- [ ] **M4.7** `services/banksync.SyncToSheets` + `fuj sync` subcommand
|
||||||
|
- [ ] **M4.8** `services/banksync.InferPayments` + `fuj infer [--dry-run]` subcommand
|
||||||
|
|
||||||
|
**Gate:** `go test -tags=integration ./internal/io/...` round-trips against test sheet; default-tag tests run on fakes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M5 — JSON-only `/api/...` routes
|
||||||
|
|
||||||
|
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||||
|
|
||||||
|
- [ ] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/`
|
||||||
|
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
||||||
|
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
||||||
|
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||||
|
|
||||||
|
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M6 — Go-native HTML frontend
|
||||||
|
|
||||||
|
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||||
|
|
||||||
|
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
||||||
|
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||||
|
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||||
|
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
||||||
|
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
|
||||||
|
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
|
||||||
|
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets
|
||||||
|
|
||||||
|
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M7 — Parallel-running watch period
|
||||||
|
|
||||||
|
Goal: prove parity over real time before flipping the default.
|
||||||
|
|
||||||
|
- [ ] **M7.1** Add Go service to `docker-compose.yml` on different port (alongside Python container)
|
||||||
|
- [ ] **M7.2** Set up `parity-nightly.yml` Gitea workflow: boot both, replay fixed transaction script, fail on diff
|
||||||
|
- [ ] **M7.3** Run `make parity` daily for 7–14 days, log any diffs; investigate and fix root cause (don't just allowlist)
|
||||||
|
- [ ] **M7.4** Manual feature parity check: walk through every UI feature on both sides, sign off in Notes section
|
||||||
|
|
||||||
|
**Gate:** Zero non-allowlisted JSON diffs over 7 consecutive days, including a sync-bank execution + flush + attendance update; user sign-off on UI feature parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M8 — Cutover + Python retirement
|
||||||
|
|
||||||
|
Goal: Go is the one true backend.
|
||||||
|
|
||||||
|
- [ ] **M8.1** Update bookmarks, README, CLAUDE.md to point at Go (`make web` aliases to `make web-go`)
|
||||||
|
- [ ] **M8.2** Run Go-only for 2 weeks including a month-end settlement; keep Python container available but unrouted
|
||||||
|
- [ ] **M8.3** Manual reconciliation review: produce a balance report on `python-final` and on Go for the same period; sign off they match
|
||||||
|
- [ ] **M8.4** Tag final Python image as `python-final` in registry; remove Python service from `docker-compose.yml`
|
||||||
|
- [ ] **M8.5** Delete [app.py](app.py), [scripts/](scripts/), Python `Dockerfile`, [tests/](tests/), `pyproject.toml`, `uv.lock`
|
||||||
|
- [ ] **M8.6** Update [CLAUDE.md](CLAUDE.md) to reflect Go-only state (commands, architecture, key modules); CHANGELOG entry
|
||||||
|
|
||||||
|
**Gate:** Two consecutive months of Go-only operation with end-of-month settlement complete; zero rollbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & decisions
|
||||||
|
|
||||||
|
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
||||||
|
|
||||||
|
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
||||||
|
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
||||||
424
docs/plans/2026-05-03-2349-go-backend-rewrite.md
Normal file
424
docs/plans/2026-05-03-2349-go-backend-rewrite.md
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# Plan: Full Go rewrite of the Python/Flask backend
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The current Flask app ([app.py](app.py) + [scripts/](scripts/), ~2400 LOC of
|
||||||
|
Python) handles attendance-based fee calculation, Fio bank sync, payment
|
||||||
|
reconciliation, and a server-rendered dashboard. The user wants a full
|
||||||
|
rewrite in Go with two goals:
|
||||||
|
|
||||||
|
1. **Quality Go code** as the primary outcome — idiomatic stdlib-first
|
||||||
|
design, strong typing, proper layering. The Python codebase grew
|
||||||
|
organically and mixes domain logic, IO, and HTTP concerns.
|
||||||
|
2. **Feature-parity certainty** — no behavioural drift between the Python
|
||||||
|
and Go versions on anything that touches money. Reconciliation is real
|
||||||
|
money; silent divergence is unacceptable.
|
||||||
|
|
||||||
|
**Switchable runtime**: both backends run on different TCP ports, started
|
||||||
|
independently via Makefile targets (`make web-py` on :5001, `make web-go` on
|
||||||
|
:8080). The user opens whichever they want in a browser. No reverse proxy,
|
||||||
|
no traffic-splitting, no shared frontend constraint — just two services
|
||||||
|
that read the same Google Sheets and the same `tmp/` cache.
|
||||||
|
|
||||||
|
**Frontends are allowed to diverge.** The Go web layer is designed cleanly
|
||||||
|
in its own right rather than as a byte-compatible Jinja port. Both backends
|
||||||
|
expose a JSON API (`/api/...`) with an identical contract — that's what
|
||||||
|
parity testing locks down. Rendered HTML and inline JS can be different.
|
||||||
|
|
||||||
|
## Versioning policy
|
||||||
|
|
||||||
|
- **Go**: latest stable release at project start. Pin in `go.mod` via the
|
||||||
|
`go` directive (e.g. `go 1.X`) and use the matching `golang:1.X` builder
|
||||||
|
image. Bump on each new minor as it lands stable.
|
||||||
|
- **Go libraries**: latest stable for every dependency in `go.mod`; run
|
||||||
|
`go get -u ./... && go mod tidy` at the start and quarterly thereafter.
|
||||||
|
- **Python deps** (during the parallel-run period): keep
|
||||||
|
[pyproject.toml](pyproject.toml) on its current versions to avoid
|
||||||
|
destabilizing the parity baseline; bump only after Python retires.
|
||||||
|
- **Base images**: `golang:latest-stable` builder → `gcr.io/distroless/static:latest`
|
||||||
|
runtime, both pinned by digest in CI for reproducibility.
|
||||||
|
- **CI runners**: latest stable Linux image on Gitea Actions.
|
||||||
|
|
||||||
|
The plan does not hardcode specific version numbers below — implementation
|
||||||
|
picks current-stable at the time M1 starts.
|
||||||
|
|
||||||
|
## Approach summary
|
||||||
|
|
||||||
|
- **Three-layer Go architecture**: pure domain (no IO) → IO clients (behind
|
||||||
|
interfaces, easily faked) → HTTP/services (composition).
|
||||||
|
- **Capture-then-port**: dump current Python outputs as JSON fixtures, port
|
||||||
|
Go function-by-function, assert byte-equality with `cmp.Diff`.
|
||||||
|
- **JSON contract is the spec, not the templates.** Each Python route gets
|
||||||
|
an `/api/X` shadow that returns the dict already passed to the template.
|
||||||
|
Go defines typed structs matching that shape; both sides validate against
|
||||||
|
generated JSON Schema.
|
||||||
|
- **Money is integer CZK**: existing fees are integer CZK (750/200/500);
|
||||||
|
keep it that way to avoid float drift in reconcile allocation. Where
|
||||||
|
Sheets returns floats, parse and round at the boundary.
|
||||||
|
- **Frontend rewrite, not port**: Go uses `html/template` with cleanly
|
||||||
|
organized templates and JS extracted into static files served via
|
||||||
|
`embed.FS`. Same UX (filterable table, member-detail modal, QR launcher)
|
||||||
|
but designed natively, no Jinja-port baggage.
|
||||||
|
|
||||||
|
## Go project layout
|
||||||
|
|
||||||
|
`go/` lives at the repo root alongside `scripts/` and `templates/` so both
|
||||||
|
backends share the same git history during migration.
|
||||||
|
|
||||||
|
```
|
||||||
|
go/
|
||||||
|
cmd/
|
||||||
|
fuj/main.go # single binary, subcommands: server | fees | sync | infer | reconcile
|
||||||
|
parity/main.go # diff tool: hits both backends' /api/X, prints JSON diff
|
||||||
|
internal/
|
||||||
|
domain/ # pure, no IO, no net/*
|
||||||
|
czech/ # normalize, parse_month_references
|
||||||
|
fees/ # calculate_fee, calculate_junior_fee, "?" sentinel type
|
||||||
|
money/ # parse_czk_amount, format helpers
|
||||||
|
reconcile/ # reconcile() + Ledger, MemberResult types
|
||||||
|
matching/ # _build_name_variants, match_members, infer_transaction_details
|
||||||
|
synch/ # generate_sync_id (pure hash)
|
||||||
|
io/ # IO behind interfaces, all impls have an in-memory fake
|
||||||
|
sheets/ # SheetsClient + Google impl + fake
|
||||||
|
drive/ # DriveClient for modifiedTime
|
||||||
|
fio/ # FioClient: API JSON impl + transparent-page HTML scraper
|
||||||
|
cache/ # FileCache with modifiedTime gating + two TTL knobs
|
||||||
|
services/ # composition layer; pure + IO, no HTTP
|
||||||
|
attendance/ # GetMembersWithFees, GetJuniorMembersWithFees
|
||||||
|
payments/ # FetchTransactions, FetchExceptions, BuildView
|
||||||
|
banksync/ # SyncToSheets, InferPayments (write ops)
|
||||||
|
web/
|
||||||
|
handlers/ # one file per route family
|
||||||
|
view/ # HTML view-model structs (per route)
|
||||||
|
api/ # JSON view-model structs (the parity-locked contract)
|
||||||
|
templates/ # *.tmpl, embed.FS — designed natively, not a Jinja port
|
||||||
|
static/ # js/*.js, css/*.css served via embed.FS
|
||||||
|
middleware/ # request timer, recovery, slog
|
||||||
|
config/ # mirrors scripts/config.py (env loading)
|
||||||
|
qr/ # SPD string builder + PNG via go-qrcode
|
||||||
|
tests/
|
||||||
|
fixtures/ # JSON fixtures captured from Python (PII-scrubbed)
|
||||||
|
parity/ # Go-side characterization tests (replay fixtures)
|
||||||
|
build/Dockerfile # multi-stage: latest-stable golang builder → distroless static
|
||||||
|
go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library choices
|
||||||
|
|
||||||
|
All on latest stable as per the versioning policy above.
|
||||||
|
|
||||||
|
| Concern | Pick | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP routing | `net/http` ServeMux | 8 static routes; no need for chi/gin given modern stdlib pattern matching |
|
||||||
|
| Templates | `html/template` | Auto-escaping; native Go feel |
|
||||||
|
| Static assets | `embed.FS` | Single binary, no loose files |
|
||||||
|
| Sheets/Drive | `google.golang.org/api/{sheets/v4,drive/v3}` + `option` | Official client; service-account auth via `option.WithCredentialsFile` |
|
||||||
|
| OAuth | `golang.org/x/oauth2/google` (token only; drop installed-app flow + pickle) | Production already uses service accounts |
|
||||||
|
| QR PNG | `github.com/skip2/go-qrcode` | Mature, byte-stable PNG output |
|
||||||
|
| NFKD | `golang.org/x/text/unicode/norm` + `unicode.IsMark` | Direct equivalent of `unicodedata.normalize("NFKD", ...)` |
|
||||||
|
| HTML scrape | `golang.org/x/net/html` token visitor | Counts `<table class="table">` to target the second one |
|
||||||
|
| CSV | `encoding/csv` (stdlib) | Match for Python `csv.reader` |
|
||||||
|
| Logging | `log/slog` (stdlib) | Honors `LOG_LEVEL` env |
|
||||||
|
| Diff/testing | `testing` + `github.com/google/go-cmp/cmp` | Readable `cmp.Diff` for parity assertions |
|
||||||
|
| Lint | `golangci-lint` (govet, staticcheck, errcheck, gofumpt, unused) | Standard quality gate |
|
||||||
|
|
||||||
|
## Migration sequencing — eight milestones with hard gates
|
||||||
|
|
||||||
|
**M1 — Skeleton + tooling.** Create `go/` tree, `go.mod` (latest stable
|
||||||
|
Go), Makefile targets (`go-build`, `go-test`, `go-run`, `web-go`),
|
||||||
|
`golangci-lint` config. `cmd/fuj server` prints a hello + version and
|
||||||
|
listens on :8080.
|
||||||
|
*Gate:* `make go-build` succeeds; `make web-go` serves a "hello" page on
|
||||||
|
:8080 in parallel with `make web` on :5001; lint clean.
|
||||||
|
|
||||||
|
**M2 — Pure-domain helpers, port leaf-first.** Order:
|
||||||
|
[czech_utils.py](scripts/czech_utils.py) `normalize` → `parse_month_references` →
|
||||||
|
[attendance.py](scripts/attendance.py) `calculate_fee`/`calculate_junior_fee` →
|
||||||
|
[infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` →
|
||||||
|
[sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` →
|
||||||
|
[match_payments.py](scripts/match_payments.py) helpers (`_build_name_variants`,
|
||||||
|
`match_members`, `infer_transaction_details`, `format_date`) → `reconcile`.
|
||||||
|
Each gets a Go unit test plus a parity test driven by JSON fixtures from M3.
|
||||||
|
Also: `fuj fees` and `fuj reconcile` subcommands wired up (pure-domain CLIs).
|
||||||
|
*Gate:* All ported helpers pass parity tests.
|
||||||
|
|
||||||
|
**M3 — Fixture capture + characterization framework.** Build
|
||||||
|
`scripts/capture_fixtures.py` (Python helper that prints function results as
|
||||||
|
JSON to stdout — user pipes to disk) and `scripts/scrub_fixtures.py`
|
||||||
|
(replaces member names with deterministic pseudonyms `Member_<8hex>`,
|
||||||
|
scrambles sender/account/VS/bank_id while preserving structural
|
||||||
|
relationships, dates, amounts, exception keys). Capture ~10 reconcile
|
||||||
|
fixtures spanning every code path: greedy, proportional with float
|
||||||
|
remainder, even-split fallback, out-of-window credit, exception override,
|
||||||
|
`other:` purpose, junior `"?"`, comma-separated multi-person, multi-month
|
||||||
|
range, unmatched.
|
||||||
|
*Gate:* `tests/fixtures/` populated and committed; M2 parity tests green.
|
||||||
|
|
||||||
|
**M4 — IO layer behind interfaces.** Implement Sheets/Drive/Fio clients
|
||||||
|
matching Python return shapes. Drop the OAuth+pickle path entirely (service
|
||||||
|
account only). All clients have in-memory fakes for tests. Wire `fuj sync`
|
||||||
|
and `fuj infer` subcommands.
|
||||||
|
*Gate:* `go test -tags=integration ./internal/io/...` round-trips against a
|
||||||
|
test sheet (separate from prod); default-tag tests use fakes.
|
||||||
|
|
||||||
|
**M5 — JSON-only `/api/...` routes.** Add 8 Go route handlers that return
|
||||||
|
JSON. Add symmetric `/api/X` shadow endpoints in [app.py](app.py) that
|
||||||
|
`jsonify` the existing view-model dict (no transformation).
|
||||||
|
*Gate:* For each route, `cmd/parity` asserts
|
||||||
|
`cmp.Diff(python.json, go.json) == ""` modulo allowlist
|
||||||
|
(`render_time.total`, `build_meta`).
|
||||||
|
|
||||||
|
**M6 — Go-native HTML frontend.** Design Go templates cleanly (not a Jinja
|
||||||
|
port). Extract JS from inline into `internal/web/static/js/*.js` served via
|
||||||
|
`embed.FS`. Vanilla JS, no framework — same UX as Python (sortable table,
|
||||||
|
member-detail modal, name filter, month range filter, QR launcher) but
|
||||||
|
organized as proper modules. Templates render the JSON API response into
|
||||||
|
HTML; frontend JS fetches additional data from `/api/X` for the modal
|
||||||
|
rather than embedding `member_data` in `<script>`.
|
||||||
|
*Gate:* Browser smoke test of all routes on :8080 covers: name filter,
|
||||||
|
month filter, modal opens with correct months/transactions/exceptions, QR
|
||||||
|
modal renders, navigation between adults/juniors/payments works.
|
||||||
|
|
||||||
|
**M7 — Parallel-running watch period.** Both `make web-py` and `make web-go`
|
||||||
|
running locally (and in production via two containers on different ports).
|
||||||
|
Daily/manual `cmd/parity` runs catch any JSON drift. The user verifies the
|
||||||
|
Go UI matches what they expect feature-by-feature against the Python UI.
|
||||||
|
Run 1–2 weeks.
|
||||||
|
*Gate:* Zero non-allowlisted JSON diffs over 7 consecutive days, including
|
||||||
|
a sync-bank execution, a flush, and an attendance update. User sign-off
|
||||||
|
that the Go UI is feature-complete.
|
||||||
|
|
||||||
|
**M8 — Cutover + Python retirement.** Switch the bookmarked URL / docs to
|
||||||
|
the Go port. Keep Python container running but unrouted (or stopped) for
|
||||||
|
1 week as rollback. Then delete [app.py](app.py), [scripts/](scripts/),
|
||||||
|
the Python `Dockerfile`, and the Python tests. Update
|
||||||
|
[CLAUDE.md](CLAUDE.md) to reflect the Go-only state.
|
||||||
|
*Gate:* Two consecutive months of Go-only operation including end-of-month
|
||||||
|
settlement.
|
||||||
|
|
||||||
|
## CLI port (decided: port as Go subcommands)
|
||||||
|
|
||||||
|
Single Go binary `fuj` with subcommands replacing the existing Makefile
|
||||||
|
targets. Each reuses the domain layer directly:
|
||||||
|
|
||||||
|
| Old | New | Backed by | Milestone |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `make fees` | `fuj fees` | `domain/fees` + `services/attendance` | M2 |
|
||||||
|
| `make reconcile` | `fuj reconcile` | `domain/reconcile` | M2 |
|
||||||
|
| `make sync-2026` | `fuj sync --year=2026` | `services/banksync.SyncToSheets` | M4 |
|
||||||
|
| `make infer` | `fuj infer [--dry-run]` | `services/banksync.InferPayments` | M4 |
|
||||||
|
| `make web` (py) | stays as Python `make web-py` until M8 | — | — |
|
||||||
|
| `make web-go` | `fuj server` | `web/handlers` | M1 |
|
||||||
|
|
||||||
|
Makefile targets get rewritten to invoke `./bin/fuj <subcommand>` once each
|
||||||
|
is ported. The Python `make` targets for already-ported commands stay as
|
||||||
|
`make X-py` aliases until M8, so you can run either side for cross-checks.
|
||||||
|
|
||||||
|
## JSON API contract strategy
|
||||||
|
|
||||||
|
**Go-defines, Python-conforms** with a 1-step bootstrap:
|
||||||
|
|
||||||
|
1. Run Python locally and dump `result["members"]`, `formatted_results`,
|
||||||
|
`monthly_totals`, etc., to JSON. This is the spec.
|
||||||
|
2. Hand-author Go structs with explicit `json:` tags matching exact Python
|
||||||
|
keys (`total_balance`, `original_expected`, `attendance_count` — no
|
||||||
|
reliance on default lowercasing).
|
||||||
|
3. Generate `tests/fixtures/api-schema/*.schema.json` from the Go structs
|
||||||
|
using `github.com/invopop/jsonschema`. Commit them.
|
||||||
|
4. Add a Python-side schema validator running in CI against the new
|
||||||
|
`/api/X` responses.
|
||||||
|
|
||||||
|
**Two known-tricky shapes:**
|
||||||
|
|
||||||
|
- Junior `expected: int | "?"` →
|
||||||
|
```go
|
||||||
|
type Expected struct{ Value int; Unknown bool }
|
||||||
|
// MarshalJSON emits 42 or "?"
|
||||||
|
```
|
||||||
|
Same for `original_expected`.
|
||||||
|
- Tuple dict keys `(normalize(name), normalize(period))` for exceptions —
|
||||||
|
internal only, never crosses JSON. Use
|
||||||
|
`map[ExceptionKey]Exception` with `ExceptionKey struct{ Name, Period string }`.
|
||||||
|
|
||||||
|
## Characterization test harness — two tiers
|
||||||
|
|
||||||
|
(HTML rendering parity dropped: frontends are intentionally different.)
|
||||||
|
|
||||||
|
**Tier 1 — Pure-function parity** (fast, every commit). Fixtures at
|
||||||
|
`tests/fixtures/pure/<func>/<case>.json` containing `{input, output}`,
|
||||||
|
captured once via `scripts/capture_fixtures.py`. Go test reads each, calls
|
||||||
|
the ported function, asserts deep equality with `cmp.Diff`. Functions in
|
||||||
|
scope: `normalize`, `parse_month_references`, `parse_czk_amount`,
|
||||||
|
`parse_czech_amount`, `parse_czech_date`, `format_date`,
|
||||||
|
`_build_name_variants`, `match_members`, `infer_transaction_details`,
|
||||||
|
`generate_sync_id`, `calculate_fee`, `calculate_junior_fee`, `reconcile`.
|
||||||
|
|
||||||
|
**Tier 2 — JSON API parity** (medium, on PR + nightly). `cmd/parity/main.go`
|
||||||
|
hits both `:5001/api/X` and `:8080/api/X` with a fixture-seeded `tmp/`
|
||||||
|
cache, normalizes volatile fields (`render_time`, build metadata), asserts
|
||||||
|
byte-equality. Cache freezing: pre-populate `tmp/*_cache.json` from
|
||||||
|
scrubbed snapshots so both backends read identical data.
|
||||||
|
|
||||||
|
**PII scrubbing** is mandatory ([CLAUDE.md](CLAUDE.md): "Member data must
|
||||||
|
never be committed"). `scripts/scrub_fixtures.py` produces deterministic
|
||||||
|
pseudonyms preserving uniqueness and structural relationships. Only
|
||||||
|
scrubbed fixtures land in `tests/fixtures/`; raw `tmp/*.json` stays
|
||||||
|
gitignored.
|
||||||
|
|
||||||
|
## Side-by-side runtime
|
||||||
|
|
||||||
|
Two services on different ports, started independently. No reverse proxy.
|
||||||
|
|
||||||
|
```
|
||||||
|
make web-py # Python on :5001 (existing target, perhaps renamed from `make web`)
|
||||||
|
make web-go # Go on :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Both read the same Google Sheets and write to the same `tmp/` cache
|
||||||
|
directory. The user opens `localhost:5001` or `localhost:8080` directly to
|
||||||
|
A/B compare.
|
||||||
|
|
||||||
|
**Cache directory coordination**: both backends use `tmp/`. Go writes via
|
||||||
|
`os.WriteFile` to `tmp/<key>_cache.json.tmp` then `os.Rename` (atomic on
|
||||||
|
Linux). Python's writes are pre-existing-non-atomic; accept until Python
|
||||||
|
retires.
|
||||||
|
|
||||||
|
**Sync coordination**: `/sync-bank` is non-idempotent under concurrency.
|
||||||
|
Both backends `flock` on `tmp/sync.lock`; Go uses `syscall.Flock`. (In
|
||||||
|
practice the user is unlikely to trigger sync from both UIs at once, but
|
||||||
|
the lock is cheap insurance.)
|
||||||
|
|
||||||
|
**Production deployment**: keep the existing Python container; add a Go
|
||||||
|
container in `docker-compose.yml` exposed on a different port. After M8,
|
||||||
|
remove the Python service.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Currently zero test CI ([.gitea/workflows/build.yaml](.gitea/workflows/build.yaml)
|
||||||
|
only does `docker build`/`push`). Add `/.gitea/workflows/test.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
python-tests: # fix M3 broken-test references first
|
||||||
|
- uv sync && pytest tests/
|
||||||
|
go-tests:
|
||||||
|
- cd go && go test -race ./...
|
||||||
|
- cd go && golangci-lint run
|
||||||
|
parity-pure: # Tier 1
|
||||||
|
- cd go && go test -tags=parity ./tests/parity/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch protection: `python-tests`, `go-tests`, `parity-pure` block merge.
|
||||||
|
Tier-2 parity runs nightly via `parity-nightly.yml` (boots both servers
|
||||||
|
via docker-compose with seeded caches, replays a fixed transaction script,
|
||||||
|
fails on any non-allowlisted diff).
|
||||||
|
|
||||||
|
A new Go `build/Dockerfile` (multi-stage: latest-stable `golang` builder →
|
||||||
|
`gcr.io/distroless/static:latest`, both pinned by digest) mirrors the
|
||||||
|
existing Python build job and produces a single static binary image.
|
||||||
|
|
||||||
|
## Risk register (top 4)
|
||||||
|
|
||||||
|
(Template auto-escape divergence dropped: irrelevant when frontends differ.)
|
||||||
|
|
||||||
|
1. **Sync ID hash drift** — HIGH/HIGH. Python builds the SHA-256 input by
|
||||||
|
`str()`-ing each field then `.lower()`-ing the joined string;
|
||||||
|
`str(750.0) == "750.0"`, `str(750) == "750"`. If Sheets API returns
|
||||||
|
floats in Python but Go unmarshals as int, `750` vs `750.0` → different
|
||||||
|
hash → duplicate rows. *Mitigation:* dedicated parity test with ~50
|
||||||
|
real-row fixtures; if Go can't reproduce Python's float string format,
|
||||||
|
normalize at the boundary (round to 2 decimals, format with explicit
|
||||||
|
precision).
|
||||||
|
2. **Float allocation in `reconcile()` proportional phase** — HIGH/MEDIUM.
|
||||||
|
Python's "last month absorbs remainder" depends on dict iteration order;
|
||||||
|
Go map iteration is randomized. *Mitigation:* always iterate
|
||||||
|
`sorted_months` explicitly in Go, never the map. Lock the distribution
|
||||||
|
with a parity test on (300, 300, 150) months × 751-CZK payment.
|
||||||
|
3. **NFKD edge cases** — MEDIUM/MEDIUM. Python `unicodedata` and Go
|
||||||
|
`golang.org/x/text` use the same algorithm but can differ on niche
|
||||||
|
compatibility decompositions if `x/text` is older than CPython's tables.
|
||||||
|
*Mitigation:* parity test with every distinct character ever observed in
|
||||||
|
member names; pin `x/text` version explicitly.
|
||||||
|
4. **Czech month parser semantics** — MEDIUM/MEDIUM. Wrap-around year
|
||||||
|
inference (`if start_m > end_m and m >= start_m: year = default_year - 1`)
|
||||||
|
plus the "month >= 10 → previous year" heuristic are easy to mis-port.
|
||||||
|
*Mitigation:* port table and algorithm verbatim line-for-line; parity
|
||||||
|
test with ~30 real `message`-field fixture strings.
|
||||||
|
|
||||||
|
## Cutover plan
|
||||||
|
|
||||||
|
Simpler without a proxy in the middle:
|
||||||
|
|
||||||
|
1. After M7's 7-day clean window + user sign-off, treat Go as primary.
|
||||||
|
Update bookmarks, docs, `make web` to point at Go.
|
||||||
|
2. Keep `make web-py` available for 1-week rollback. Run both containers
|
||||||
|
in production but only point users at the Go one.
|
||||||
|
3. Watch 2 weeks including a month-end settlement on Go-only.
|
||||||
|
4. Decommission Python: remove from `docker-compose.yml`, delete
|
||||||
|
[app.py](app.py) and [scripts/](scripts/), update
|
||||||
|
[CLAUDE.md](CLAUDE.md). Keep image tagged `python-final` in registry as
|
||||||
|
a 6-month rollback option.
|
||||||
|
|
||||||
|
**Retirement criteria:** zero parity-diff incidents in last 30 days, zero
|
||||||
|
rollbacks, two month-end settlements completed Go-only, manual
|
||||||
|
reconciliation review against `python-final` signed off.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- [scripts/match_payments.py](scripts/match_payments.py) — `reconcile()` is
|
||||||
|
the single most load-bearing function (~200 lines of allocation logic)
|
||||||
|
that must port byte-equivalently.
|
||||||
|
- [scripts/czech_utils.py](scripts/czech_utils.py) — `normalize` and
|
||||||
|
`parse_month_references` underpin every member/month match across the
|
||||||
|
system. 45 Czech month declensions, range wrap-around, year inference.
|
||||||
|
- [app.py](app.py) — defines the 8-route HTTP surface and view-model
|
||||||
|
shapes. The spec for the Go web layer's JSON API.
|
||||||
|
- [scripts/sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) —
|
||||||
|
`generate_sync_id` defines the dedup contract against existing rows in
|
||||||
|
the live sheet. Any drift creates duplicates.
|
||||||
|
- [scripts/attendance.py](scripts/attendance.py) — fee math + merged-month
|
||||||
|
logic + junior `"?"` sentinel.
|
||||||
|
- [scripts/cache_utils.py](scripts/cache_utils.py) — Drive `modifiedTime`
|
||||||
|
gating + two-TTL fallback that must be reproduced for shared-cache
|
||||||
|
safety.
|
||||||
|
- [templates/adults.html](templates/adults.html) — read for the JSON shape
|
||||||
|
the existing inline JS consumes (`member_data`); the Go frontend doesn't
|
||||||
|
have to mirror the template, but the JSON contract derived from this
|
||||||
|
page's data injection is the parity spec.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end checks per milestone:
|
||||||
|
|
||||||
|
- **M1**: `make go-build && ./bin/fuj server --help` prints subcommand
|
||||||
|
list. `make web-go` serves :8080 in parallel with `make web-py` on :5001.
|
||||||
|
- **M2-M3**: `cd go && go test -tags=parity ./tests/parity/pure/...` green.
|
||||||
|
Spot-check: feed a known Czech-message string through both
|
||||||
|
`parse_month_references` implementations, diff outputs.
|
||||||
|
- **M4**: `go test -tags=integration ./internal/io/sheets/...` round-trips
|
||||||
|
against a test sheet (separate from prod).
|
||||||
|
- **M5**: `curl localhost:5001/api/adults | jq -S . > py.json && curl
|
||||||
|
localhost:8080/api/adults | jq -S . > go.json && diff py.json go.json` —
|
||||||
|
empty diff modulo allowlist.
|
||||||
|
- **M6**: Browser open `localhost:8080/adults`, click a member row, modal
|
||||||
|
opens with all months / transactions / exceptions correctly populated.
|
||||||
|
Same on `/juniors`. Click a Pay button → QR loads. Name filter and month
|
||||||
|
range filter work.
|
||||||
|
- **M7**: Run `cd go && ./bin/parity --base http://localhost:5001
|
||||||
|
--candidate http://localhost:8080 --routes adults,juniors,payments`
|
||||||
|
daily for 7 days, zero non-allowlisted diffs. User confirms Go UI is
|
||||||
|
feature-complete vs Python UI side-by-side.
|
||||||
|
- **M8**: `make web-py` removed from Makefile; `make web` points at Go;
|
||||||
|
manual end-of-month settlement on Go matches the prior month's
|
||||||
|
Python-produced report.
|
||||||
|
|
||||||
|
## Open questions / forks the user can override at review
|
||||||
|
|
||||||
|
- **Frontend JS organization in M6**: default is vanilla JS in separate
|
||||||
|
files via `embed.FS`. If the user wants HTMX, Alpine.js, or a small
|
||||||
|
framework, raise it before M6.
|
||||||
|
- **CI host**: Gitea Actions assumed (matches existing
|
||||||
|
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml)).
|
||||||
|
- **Test sheet for M4 integration tests**: would need provisioning.
|
||||||
|
Confirm whether to use a copy of the production sheet (PII!) or a
|
||||||
|
synthetic one seeded by the fixture-capture process.
|
||||||
233
docs/plans/2026-05-04-1115-go-rewrite-m1-kickoff.md
Normal file
233
docs/plans/2026-05-04-1115-go-rewrite-m1-kickoff.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Plan: Go rewrite — M1 kickoff (skeleton + tooling)
|
||||||
|
|
||||||
|
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
|
||||||
|
and the progress tracker
|
||||||
|
[2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The master plan for a full Go rewrite of the Flask backend is approved
|
||||||
|
(2026-05-04). No Go code exists yet — this plan executes **M1** end-to-end:
|
||||||
|
a working `go/` skeleton, a `fuj` binary with a `server` subcommand serving
|
||||||
|
a hello page on `:8080`, lint config, Makefile + CI integration, and an
|
||||||
|
`internal/config` package mirroring [scripts/config.py](scripts/config.py).
|
||||||
|
|
||||||
|
After M1, both backends run side-by-side locally (`make web-py` on `:5001`,
|
||||||
|
`make web-go` on `:8080`) — that side-by-side capability is what unblocks
|
||||||
|
M2's parity testing and every later milestone.
|
||||||
|
|
||||||
|
## Locked-in decisions
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | CLI dispatcher | stdlib `flag` + `os.Args[1]` switch (no cobra) |
|
||||||
|
| 2 | Go module path | `fuj-management/go` |
|
||||||
|
| 3 | Go version | `1.26` (latest stable; user toolchain is `go1.26.1`) |
|
||||||
|
| 4 | M1 scope | all 10 progress-tracker sub-tasks in one session |
|
||||||
|
| 5 | Lint | `golangci-lint` with govet, staticcheck, errcheck, gofumpt, unused |
|
||||||
|
| 6 | Logging | `log/slog` text handler, level from `LOG_LEVEL` env |
|
||||||
|
| 7 | HTTP | `net/http.ServeMux` (Go 1.22+ pattern matching) |
|
||||||
|
| 8 | Container base | `golang:1.26` builder → `gcr.io/distroless/static:nonroot` runtime |
|
||||||
|
| 9 | CI | extend [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) with a `go-build` job parallel to existing Python `build` job; tag suffix `-go` |
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
```
|
||||||
|
go/
|
||||||
|
go.mod # module fuj-management/go, go 1.26
|
||||||
|
go.sum # empty / generated
|
||||||
|
.golangci.yml # govet, staticcheck, errcheck, gofumpt, unused
|
||||||
|
cmd/fuj/main.go # subcommand dispatcher + version vars
|
||||||
|
internal/
|
||||||
|
config/config.go # env loader mirroring scripts/config.py
|
||||||
|
logging/logger.go # slog setup honoring LOG_LEVEL
|
||||||
|
web/
|
||||||
|
server.go # `fuj server` handler: ServeMux on :8080, hello page
|
||||||
|
middleware/timer.go # request-timer middleware (parity with Python `get_render_time`)
|
||||||
|
build/
|
||||||
|
Dockerfile # multi-stage golang:1.26 → distroless/static
|
||||||
|
```
|
||||||
|
|
||||||
|
No `embed.FS`, no templates, no static assets in M1 — the hello page is
|
||||||
|
inline HTML in `server.go`. Templates land in M6.
|
||||||
|
|
||||||
|
## Files to edit
|
||||||
|
|
||||||
|
- [Makefile](Makefile) — add Go targets, rename `web` → `web-py`, keep
|
||||||
|
`web` as transitional alias to `web-py` until M8.
|
||||||
|
- [.gitignore](.gitignore) — add `bin/` and `go/.cache/` (if any).
|
||||||
|
- [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — add
|
||||||
|
`go-build` job that builds and pushes `<tag>-go` image.
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — top-of-file entry per CLAUDE.md convention.
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
— tick M1.1–M1.10 with commit SHAs as they land.
|
||||||
|
|
||||||
|
## Execution sequence
|
||||||
|
|
||||||
|
Order is tight: each step keeps the tree compilable and lint-clean.
|
||||||
|
|
||||||
|
1. **Skeleton (M1.1)** — `mkdir -p go/{cmd/fuj,internal/{config,logging,web/middleware},build}` and `cd go && go mod init fuj-management/go`. Pin `go 1.26` in `go.mod`.
|
||||||
|
|
||||||
|
2. **Config + logger (M1.8, M1.9)** — write `internal/config/config.go` mirroring [scripts/config.py](scripts/config.py): exported constants for `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID`, env-driven `CredentialsPath`, `BankAccount`, `CacheTTL`, `CacheAPICheckTTL`, `LogLevel`, `FioAPIToken`. Write `internal/logging/logger.go` with a `New() *slog.Logger` honoring `LOG_LEVEL` (`DEBUG|INFO|WARN|ERROR`).
|
||||||
|
|
||||||
|
3. **Web middleware + handler (M1.3)** — `internal/web/middleware/timer.go` logs `method path status ms` for every request. `internal/web/server.go` exposes `Run(ctx, addr) error`: `http.ServeMux` with `GET /` returning a minimal HTML hello page that includes `version`, `commit`, and `buildDate` (linker-injected via `-X main.version=…`).
|
||||||
|
|
||||||
|
4. **Subcommand dispatcher (M1.2)** — `cmd/fuj/main.go`:
|
||||||
|
- Package-level `var version, commit, buildDate string` for `-ldflags -X` injection.
|
||||||
|
- `os.Args[1]` switch over `server | version | fees | reconcile | sync | infer | help`. M1 implements `server` and `version`; the rest print `<cmd>: not implemented yet (lands in M2/M4)` and exit 2.
|
||||||
|
- Each subcommand parses its own `flag.NewFlagSet`. `server` flags: `--addr` (default `:8080`).
|
||||||
|
|
||||||
|
5. **Lint config (M1.6)** — `go/.golangci.yml` enabling `govet`, `staticcheck`, `errcheck`, `gofumpt`, `unused`. Run `golangci-lint run ./...` to confirm clean.
|
||||||
|
|
||||||
|
6. **Makefile (M1.4, M1.5)** — add:
|
||||||
|
```make
|
||||||
|
GO_BIN := bin/fuj
|
||||||
|
GO_SRC := go
|
||||||
|
|
||||||
|
go-build:
|
||||||
|
cd $(GO_SRC) && go build -trimpath \
|
||||||
|
-ldflags "-X main.version=$$(git describe --tags --always 2>/dev/null || echo dev) \
|
||||||
|
-X main.commit=$$(git rev-parse --short HEAD) \
|
||||||
|
-X main.buildDate=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
|
-o ../$(GO_BIN) ./cmd/fuj
|
||||||
|
|
||||||
|
go-test:
|
||||||
|
cd $(GO_SRC) && go test -race ./...
|
||||||
|
|
||||||
|
go-run: go-build
|
||||||
|
./$(GO_BIN) $(ARGS)
|
||||||
|
|
||||||
|
go-lint:
|
||||||
|
cd $(GO_SRC) && golangci-lint run ./...
|
||||||
|
|
||||||
|
web-go: go-build
|
||||||
|
./$(GO_BIN) server --addr :8080
|
||||||
|
```
|
||||||
|
Rename existing `web:` target to `web-py:` and add `web: web-py` as alias.
|
||||||
|
|
||||||
|
7. **Dockerfile + CI (M1.7)** — `go/build/Dockerfile`:
|
||||||
|
```dockerfile
|
||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go/go.mod go/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY go/ ./
|
||||||
|
ARG GIT_TAG=unknown
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${GIT_TAG} -X main.commit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/fuj ./cmd/fuj
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
COPY --from=build /out/fuj /usr/local/bin/fuj
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["/usr/local/bin/fuj","server"]
|
||||||
|
```
|
||||||
|
In [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), add a parallel job:
|
||||||
|
```yaml
|
||||||
|
build-go:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: docker login ...
|
||||||
|
- run: |
|
||||||
|
docker build -f go/build/Dockerfile \
|
||||||
|
--build-arg GIT_TAG=$TAG \
|
||||||
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
-t gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go .
|
||||||
|
docker push gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Smoke verify (M1.10)** — see Verification section below; then append a CHANGELOG entry and tick M1 boxes in the progress tracker with commit SHAs.
|
||||||
|
|
||||||
|
## Reuse / parity with Python side
|
||||||
|
|
||||||
|
- `internal/config` mirrors [scripts/config.py](scripts/config.py) **exactly** — same env var names, same defaults. No new env knobs in M1.
|
||||||
|
- Request-timer middleware records elapsed milliseconds; this is the Go-side
|
||||||
|
equivalent of the Python `get_render_time` helper that supplies
|
||||||
|
`render_time.total` to templates. Allowlisted as volatile in the future
|
||||||
|
parity diff (M5).
|
||||||
|
- Constants `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID` are
|
||||||
|
copied verbatim from [scripts/config.py](scripts/config.py); they don't
|
||||||
|
get used until M4 but live in `internal/config` from day one.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run from repo root after all changes are in place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Builds clean
|
||||||
|
make go-build && test -x bin/fuj
|
||||||
|
|
||||||
|
# 2. Lint clean
|
||||||
|
make go-lint
|
||||||
|
|
||||||
|
# 3. Subcommand dispatcher works
|
||||||
|
./bin/fuj help
|
||||||
|
./bin/fuj version # prints version/commit/buildDate
|
||||||
|
./bin/fuj fees # prints "not implemented yet" and exits 2
|
||||||
|
|
||||||
|
# 4. Server runs and hello page is served
|
||||||
|
make web-go &
|
||||||
|
GO_PID=$!
|
||||||
|
sleep 1
|
||||||
|
curl -sf http://localhost:8080/ | grep -q "fuj"
|
||||||
|
kill $GO_PID
|
||||||
|
|
||||||
|
# 5. Side-by-side: both backends up
|
||||||
|
make web-py & # :5001
|
||||||
|
PY_PID=$!
|
||||||
|
make web-go & # :8080
|
||||||
|
GO_PID=$!
|
||||||
|
sleep 2
|
||||||
|
curl -sf http://localhost:5001/ >/dev/null && echo "py OK"
|
||||||
|
curl -sf http://localhost:8080/ >/dev/null && echo "go OK"
|
||||||
|
kill $PY_PID $GO_PID
|
||||||
|
|
||||||
|
# 6. Race-free unit tests pass (none yet beyond a smoke test, but harness works)
|
||||||
|
make go-test
|
||||||
|
|
||||||
|
# 7. Docker image builds locally
|
||||||
|
docker build -f go/build/Dockerfile -t fuj-go:dev .
|
||||||
|
docker run --rm -p 8080:8080 fuj-go:dev &
|
||||||
|
sleep 1
|
||||||
|
curl -sf http://localhost:8080/ >/dev/null && echo "container OK"
|
||||||
|
docker stop $(docker ps -lq)
|
||||||
|
```
|
||||||
|
|
||||||
|
All seven steps must succeed. Then update the progress tracker and
|
||||||
|
CHANGELOG.
|
||||||
|
|
||||||
|
## Out of scope for M1 (deferred to later milestones)
|
||||||
|
|
||||||
|
- Domain logic — `czech.Normalize`, fees, reconcile, etc. → **M2**.
|
||||||
|
- Fixture capture and parity tests → **M3**.
|
||||||
|
- Sheets/Drive/Fio clients and `internal/io/*` → **M4**.
|
||||||
|
- `/api/*` JSON routes and `cmd/parity` → **M5**.
|
||||||
|
- HTML templates, static assets, `embed.FS` → **M6**.
|
||||||
|
- Removing the Python backend → **M8**.
|
||||||
|
|
||||||
|
## Open items / forks the user can override at review
|
||||||
|
|
||||||
|
- **CI tag suffix**: `<tag>-go` proposed. Alternative: separate image
|
||||||
|
repository (`fuj-management-go:<tag>`). The suffix keeps things in one
|
||||||
|
registry path; speak up if separate repos are preferred.
|
||||||
|
- **Distroless variant**: `nonroot` chosen for least privilege. If the
|
||||||
|
existing Python container runs as root and the user expects parity,
|
||||||
|
switch to `gcr.io/distroless/static` (root). Doesn't affect M1
|
||||||
|
functionality.
|
||||||
|
- **Hello page content**: minimal HTML mentioning `fuj`, version, commit,
|
||||||
|
build date, link list to future routes. Speak up if you want a different
|
||||||
|
shape — it gets thrown away in M6 anyway.
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md](docs/plans/2026-05-03-2349-go-backend-rewrite.md) — master plan (approved 2026-05-04)
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — task tracker; tick M1.1–M1.10 here
|
||||||
|
- [Makefile](Makefile) — current target structure (renaming `web` → `web-py`)
|
||||||
|
- [scripts/config.py](scripts/config.py) — source of truth for env vars / IDs that `internal/config` mirrors
|
||||||
|
- [build/Dockerfile](build/Dockerfile) — Python container (unchanged); the new Go Dockerfile lives at `go/build/Dockerfile`
|
||||||
|
- [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — extended with parallel `build-go` job
|
||||||
11
go/.golangci.yml
Normal file
11
go/.golangci.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- errcheck
|
||||||
|
- gofumpt
|
||||||
|
- unused
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
gofumpt:
|
||||||
|
extra-rules: true
|
||||||
30
go/build/Dockerfile
Normal file
30
go/build/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM golang:1.26 AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG GIT_TAG=unknown
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w \
|
||||||
|
-X main.version=${GIT_TAG} \
|
||||||
|
-X main.commit=${GIT_COMMIT} \
|
||||||
|
-X main.buildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/fuj ./cmd/fuj
|
||||||
|
|
||||||
|
FROM alpine:3
|
||||||
|
|
||||||
|
RUN addgroup -S fuj && adduser -S fuj -G fuj
|
||||||
|
|
||||||
|
COPY --from=build /out/fuj /usr/local/bin/fuj
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
USER fuj
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/fuj", "server"]
|
||||||
84
go/cmd/fuj/main.go
Normal file
84
go/cmd/fuj/main.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/logging"
|
||||||
|
"fuj-management/go/internal/web"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
commit = "unknown"
|
||||||
|
buildDate = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, args := os.Args[1], os.Args[2:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "server":
|
||||||
|
serverCmd(args)
|
||||||
|
case "version":
|
||||||
|
versionCmd()
|
||||||
|
case "fees", "reconcile", "sync", "infer":
|
||||||
|
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M2/M4)\n", cmd)
|
||||||
|
os.Exit(2)
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
usage()
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "fuj: unknown command %q\n\n", cmd)
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverCmd(args []string) {
|
||||||
|
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||||
|
addr := fs.String("addr", "", "listen address (default from SERVER_ADDR env or :8080)")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: fuj server [--addr :8080]")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
if *addr != "" {
|
||||||
|
cfg.ServerAddr = *addr
|
||||||
|
}
|
||||||
|
|
||||||
|
logger := logging.New(cfg.LogLevel)
|
||||||
|
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||||
|
|
||||||
|
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionCmd() {
|
||||||
|
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintln(os.Stderr, `usage: fuj <command> [flags]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
server Start HTTP server (default :8080)
|
||||||
|
version Print version information
|
||||||
|
fees Calculate monthly fees [M2]
|
||||||
|
reconcile Show balance report [M2]
|
||||||
|
sync Sync Fio transactions [M4]
|
||||||
|
infer Infer payment details [M4]`)
|
||||||
|
}
|
||||||
56
go/internal/config/config.go
Normal file
56
go/internal/config/config.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Google Sheets IDs — change in code if sheets change (not from env).
|
||||||
|
const (
|
||||||
|
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
|
||||||
|
PaymentsSheetID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
|
||||||
|
JuniorSheetGID = "1213318614"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all runtime configuration loaded from environment variables.
|
||||||
|
// Mirrors scripts/config.py.
|
||||||
|
type Config struct {
|
||||||
|
CredentialsPath string
|
||||||
|
BankAccount string
|
||||||
|
CacheTTL time.Duration
|
||||||
|
CacheAPICheckTTL time.Duration
|
||||||
|
LogLevel string
|
||||||
|
FioAPIToken string
|
||||||
|
ServerAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from the environment, applying defaults that
|
||||||
|
// match the Python side.
|
||||||
|
func Load() Config {
|
||||||
|
return Config{
|
||||||
|
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||||
|
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||||
|
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||||
|
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||||
|
LogLevel: env("LOG_LEVEL", "INFO"),
|
||||||
|
FioAPIToken: env("FIO_API_TOKEN", ""),
|
||||||
|
ServerAddr: env("SERVER_ADDR", ":8080"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDuration(key string, defaultSeconds int) time.Duration {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
return time.Duration(n) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(defaultSeconds) * time.Second
|
||||||
|
}
|
||||||
24
go/internal/logging/logger.go
Normal file
24
go/internal/logging/logger.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New returns a slog.Logger at the given level (DEBUG|INFO|WARN|ERROR).
|
||||||
|
// Pass config.Config.LogLevel as the argument. Defaults to INFO on unrecognised input.
|
||||||
|
func New(level string) *slog.Logger {
|
||||||
|
var l slog.Level
|
||||||
|
switch strings.ToUpper(level) {
|
||||||
|
case "DEBUG":
|
||||||
|
l = slog.LevelDebug
|
||||||
|
case "WARN", "WARNING":
|
||||||
|
l = slog.LevelWarn
|
||||||
|
case "ERROR":
|
||||||
|
l = slog.LevelError
|
||||||
|
default:
|
||||||
|
l = slog.LevelInfo
|
||||||
|
}
|
||||||
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}))
|
||||||
|
}
|
||||||
34
go/internal/web/middleware/timer.go
Normal file
34
go/internal/web/middleware/timer.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sw *statusWriter) WriteHeader(code int) {
|
||||||
|
sw.status = code
|
||||||
|
sw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestTimer logs method, path, status, and elapsed milliseconds for every
|
||||||
|
// request. Parity with Python's get_render_time — the elapsed value maps to
|
||||||
|
// render_time.total in the M5 JSON allowlist.
|
||||||
|
func RequestTimer(logger *slog.Logger, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(sw, r)
|
||||||
|
logger.Info("req",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", sw.status,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
32
go/internal/web/server.go
Normal file
32
go/internal/web/server.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildInfo carries the linker-injected build metadata.
|
||||||
|
type BuildInfo struct {
|
||||||
|
Version string
|
||||||
|
Commit string
|
||||||
|
BuildDate string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run registers routes and starts the HTTP server on addr.
|
||||||
|
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /{$}", helloHandler(build))
|
||||||
|
|
||||||
|
logger.Info("starting server", "addr", addr)
|
||||||
|
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
func helloHandler(build BuildInfo) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
|
||||||
|
build.Version, build.Commit, build.BuildDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user