Redesigns the order list so unpaid and paid orders share a consistent
card layout, with clearer payment context and labeled metadata users
need before paying via Swish.
- Split list into Obetalda/Tidigare sections with pending orders first
- Pending cards: preview box, labeled Beställnings-ID, price row, Betala 49 kr
- Completed cards: same header/preview layout, prominent Spåra brev button
- Replace em-dash pay label and update unit/E2E selectors
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds backend endpoints and frontend edit page so pending orders can be updated or soft-cancelled without admin intervention.
Co-authored-by: Cursor <cursoragent@cursor.com>
Forgejo workflow_dispatch requires an explicit input type; without it the
UI showed invalidinputtype. Clarify README: workflow ref vs version tag.
Co-authored-by: Cursor <cursoragent@cursor.com>
The docker profile disables mail.smtp.starttls for Mailpit; prod runs
docker+prod so Resend saw AUTH before STARTTLS (538). Re-enable auth and
STARTTLS in application-prod.yml.
Co-authored-by: Cursor <cursoragent@cursor.com>
Deploy workflow now writes MAIL_* and APP_PUBLIC_BASE_URL from Actions
secrets into the server .env so Resend SMTP works after domain verify.
Document Resend-only setup, Forgejo secret names, and prod expose-token off.
Co-authored-by: Cursor <cursoragent@cursor.com>
Operators can fix prod admin passwords without email via Byt lösenord;
end users can use forgot-password when SMTP is configured. Local and CI
use Mailpit to capture outbound mail and verify reset links end-to-end.
- Backend: V8 password_reset_tokens, PasswordResetService, EmailService,
POST /api/auth/forgot-password, reset-password, change-password
- Optional testToken in forgot-password response (docker profile only, for E2E)
- Frontend: ForgotPasswordPage, ResetPasswordPage, ChangePasswordPage,
routes, login link, header Byt lösenord
- Mailpit (ghcr.io/axllent/mailpit:v1.28) in docker-compose + e2e stack
- E2E: password-reset.spec.ts + Mailpit API helper tests SMTP delivery
- Separate dev/e2e Docker image names to avoid overwriting bilhej-frontend
- Docs: README email section, production-email-checklist, .env.example
- Unit/integration tests for reset, change password, and Vitest page specs
Co-authored-by: Cursor <cursoragent@cursor.com>
First-time host nginx setup needs HTTP-only vhost before certbot can
issue certs; the full bilhej.nginx.conf 443 block fails nginx -t until
those files exist.
- Add docker/bilhej.nginx.http.conf for ACME phase
- Reorder README one-time setup: HTTP vhost, certbot, then full config
Backend crashed on startup because the prod DB still records V6 (and
possibly V2/V4) from when seeds lived in db/migration, while prod only
loads schema migrations. ignore-migration-patterns alone did not prevent
validate failure on the runner.
- Run Flyway repair before migrate on the prod profile
- Add ProdFlywayConfigTest for repair-then-migrate order
- Document the V6 error in README deploy troubleshooting
Root check only ran frontend lint, unit tests, and E2E, so backend JUnit
and JaCoCo gates were skipped despite AGENTS.md documenting otherwise.
- Make check depend on :backend:check and frontendE2E as siblings
- Update AGENTS.md comment to match the real task order
Production deploy failed with no backend logs before rollback. Print
backend and postgres logs on failure, wait longer for JVM startup, and
probe /api/payment/swish-info instead of vehicle lookup (no external scrape).
- Document proof-first troubleshooting in README
- No volume reset workflow; fix only after reading job logs
Docker Compose interpolates $VAR in .env files. Passwords like ...$A72y...
were truncated and the backend failed health checks, triggering rollback.
- Escape $ as $$ when writing production secrets to .env
- Document that deploy handles literal $ in Forgejo secrets
Local verification should match Forgejo CI. Wire frontendE2E into check
and point it at docker-compose.e2e.yml with the same test env vars.
- Add frontendE2E to check after frontend unit tests
- Run docker-compose.e2e.yml directly from Gradle with CI secrets
- Update npm test:e2e:ci to use the e2e compose file
The admin search label and parallel compose tests made strict-mode
Playwright locators ambiguous after the dashboard rework.
- Assert table columns via columnheader roles instead of getByText
- Target seeded order by ID when opening the message modal
Co-authored-by: Cursor <cursoragent@cursor.com>
Operators need IntelliJ-style GUI access to Docker Postgres and clear
steps for manual prod cleanup without wiping volumes.
- Add Database access section with IntelliJ, DBeaver, and SSH tunnel steps
- Document dev-only accounts, manual SQL cleanup, and hashPassword task
- Note Flyway dev-migration split and admin bootstrap in AGENTS.md
Production must not ship test users, demo orders, or test1234. Dev and CI
still need seeded users for e2e. Prod creates one admin from deploy secrets.
- Move V2/V4/V6 seed migrations to db/dev-migration
- Add application-prod.yml with schema-only Flyway and ignore-missing for moved seeds
- Add AdminBootstrap to create admin from ADMIN_EMAIL and ADMIN_PASSWORD
- Wire docker,prod profile, deploy secrets, and localhost:5433 for SSH DB access
- Add hashPassword Gradle task for optional manual bcrypt generation
Manual Swish flow means users often pay later; admins match payments via
order ID or regnr under Att göra.
- User flow: create order, leave payment, pay from orders page
- Admin: find order via partial ID, full ID, and plate search
- Assert unpaid orders appear under Väntar, not Att göra
- Use unique plates per run to avoid collisions with seed data
Admins need to find orders quickly and read full letter text without a
cramped table column.
- Make stat cards clickable filters (Totalt, Att göra, Betalda, Väntar)
- Add search by partial order ID or registration number
- Show shortened order ID in table with full ID on hover
- Replace message column with "Visa meddelande" opening a modal
- Keep expanded row for tracking only; remove duplicate brevtext block
- Update AdminDashboard unit tests and admin-dashboard e2e specs
The full UUID is required as the Swish message but was easy to miss when
embedded only in instruction text.
- Show beställnings-ID in a dedicated box above payment summary
- Point Swish instructions to that ID instead of repeating inline
- Add PaymentRedirect unit test for full order ID display
Users who leave the payment step can return later and still see what
they ordered. Unpaid orders get a clear path back to Swish checkout.
- Add letterText to frontend Order type
- Show beställnings-ID, message, and formatted date on each order card
- Add "Betala nu" link to payment route for pending_payment orders
- Extend OrdersPage unit tests and order-history e2e for pay-later flow
Users need the full message on Mina beställningar after creating an
order. The admin API already exposed letterText; user list and payment
confirm endpoints did not.
- Add letterText field to OrderResponse DTO
- Map letterText in OrderController.toResponse and PaymentController.toResponse
- Assert letterText in OrderControllerTest list and create expectations
Add ports mapping 3001:80 to the prod frontend service so the application
is accessible from the server at http://srvr.nu:3001 for testing before
DNS is pointed to bilhej.se. Backend remains internal-only (no host port).
The deploy pipeline had two critical bugs:
1. Health check used /api/vehicles/ZZZ999 with curl -f. This endpoint
returns HTTP 404 for unknown plates (correct behavior), which curl -f
treated as a failure. The backend was actually healthy.
Fix: use /api/vehicles/ABC123 (seeded in V6 migration, always 200)
and remove -f flag from curl.
2. No rollback on failure. If health checks failed, containers stayed
running forever because the pipeline exited 1 without stopping them.
Fix: combine health checks into one step. If either fails, run
'docker compose down' (without -v, so DB volume is preserved) before
exiting with failure.
The production deploy failed because port 3000 was already bound by the
dev frontend container (bilhej-frontend). The prod frontend doesn't need
a host port at all — nginx talks to it via the external 'web' network.
Changes:
- Remove host port binding (3000:80) from prod frontend
- Remove unused 'certs' volume from prod compose
- Use --project-name bilhej-prod in deploy workflow to isolate prod
containers/networks from dev and e2e environments
- Add 'docker compose down' before 'up' for clean deploys
- Update health check network names to bilhej-prod_default
The deploy workflow failed when re-running with the same version tag
because Git rejects pushing a tag that already exists on the remote.
- Delete local tag first (ignore if missing)
- Delete remote tag first (ignore if missing)
- Create and push the tag fresh
This makes deploys idempotent: retrying a failed deploy with the same
version (e.g., v0.1.0) will succeed by moving the tag to the current
commit. For a new deploy, the delete commands silently do nothing.
The production backend Dockerfile was looking for Gradle files in a
backend/ subdirectory that doesn't exist in the repo structure:
- gradlew lives at repo root, not backend/gradlew
- gradle/ wrapper dir lives at repo root, not backend/gradle/
- settings.gradle lives at repo root, not backend/settings.gradle
Fixed by copying root-level Gradle files and placing backend-specific
files in the backend/ subdirectory. Also added :backend: subproject
prefix to Gradle tasks and corrected the output JAR path.
This fixes the deploy pipeline failure:
failed to calculate checksum: /backend/settings.gradle: not found
Adds a comprehensive 'Production Deployment' section covering:
- One-time server setup (Forgejo secrets, DNS, SSL certbot, nginx config)
- How to trigger a deploy from the Forgejo Actions UI
- What the deploy pipeline does step-by-step
- Architecture diagram showing how nginx, frontend, backend, and postgres
containers interact on the production server
- Rollback procedure using git tags and docker compose
This documents the deploy.yml workflow and bilhej.nginx.conf added in
the previous commit.
Add a manually-triggered deploy workflow that builds production Docker
images and starts the stack on the srvr.nu server.
- : workflow_dispatch with version input,
writes production .env from Forgejo secrets, builds and starts the
docker-compose.prod.yml stack, runs health checks via temporary curl
containers on the bilhej_default Docker network, tags the git commit.
- : nginx server block for bilhej.se.
Handles HTTP→HTTPS redirect, SSL termination with Let's Encrypt certs,
and proxies all traffic to the bilhej-frontend-prod container on the
Docker 'web' network. The frontend container handles /api/ proxying
to the backend internally.
To deploy:
1. Add production secrets to Forgejo (Settings → Actions → Secrets)
2. Trigger deploy from Actions → Deploy to Production
3. Run certbot for bilhej.se SSL (one-time setup)
4. Add docker/bilhej.nginx.conf to srvr.nu nginx container
5. Point bilhej.se DNS A record to srvr.nu IP
The 4-column table had a trailing empty column on the right because
the Status column was removed but the right border was still placed
after it. This created a visual glitch in the Forgejo log viewer.
- Convert to a clean 3-column table: Layer | Lines | Branch
- Remove emojis from table cells (they're double-width and break alignment)
- Add a plain-text pass/fail line below the table instead
- Use consistent 7-char padding for all percentage values so % signs align
Result: coverage summary renders cleanly in the CI job log.
Adds a step after backend coverage that parses the JaCoCo XML report
and prints a formatted table showing line and branch coverage with
pass/fail status against thresholds (70% lines, 60% branches).
The frontend coverage is already visible from Vitest's built-in text
reporter which prints during npm run test:coverage.
Both HTML reports remain downloadable as artifacts (backend-coverage
and frontend-coverage ZIPs).
Result: coverage numbers are visible at a glance in the CI job log
without needing to download and unzip artifacts.
actions/upload-artifact@v4 requires GHES features not available in
self-hosted Forgejo, causing artifact upload failures with:
GHESNotSupportedError: upload-artifact@v4+ are not supported on GHES.
- Downgrade both coverage upload steps from v4 to v3
- v3 uses a compatible upload mechanism that works on Forgejo
Keeps the artifact upload functionality so coverage HTML reports remain
downloadable from the workflow run page.
The lint-and-test job was running tests twice:
- 'Backend unit tests' ran tests without coverage
- 'Backend coverage' ran the same tests again with JaCoCo
- 'Frontend unit tests' ran tests without coverage
- 'Frontend coverage' ran the same tests again with v8 coverage
This wasted ~2x test time for no benefit since coverage steps already
run all tests.
- Remove 'Backend unit tests' and 'Frontend unit tests' steps
- Keep only coverage steps (jacocoTestCoverageVerification and test:coverage)
- Add artifact upload steps for both coverage HTML reports:
- backend-coverage: backend/build/reports/jacoco/test/html/
- frontend-coverage: frontend/coverage/
- 7-day retention to avoid storage bloat
Result: lint-and-test job runs faster (no duplicate test runs) and
produces downloadable HTML coverage reports visible in the Forgejo
Actions UI.
The Forgejo runner uses catthehacker/ubuntu:act-latest which does not have
a real GitHub Actions cache backend. actions/setup-node@v4 with cache: npm
spends ~4m44s trying to restore a non-existent cache during setup, and then
~4m40s in the post-job hook trying to save the cache during 'Complete job'.
- Remove cache: npm and cache-dependency-path from setup-node step
- npm ci without cache is fast enough for this project size (~10-20s)
Expected result: lint-and-test job drops from ~11m to ~2m total.
The postgres:16 image declares a VOLUME for /var/lib/postgresql/data.
Docker Compose creates an anonymous volume that persists across CI runs.
When a Flyway migration file is modified, the next run sees a checksum
mismatch because the old migration is already recorded in the schema_history
table in the stale volume.
- Add tmpfs: [/var/lib/postgresql/data] to the postgres service
- This keeps data in RAM only, guaranteeing a completely fresh database
on every E2E run with no persistent state between invocations
Result: eliminates FlywayValidateException caused by migration checksum
mismatches in CI.
fix: add preview.allowedHosts and preview.host to vite.config.ts
Vite preview server blocks requests from non-localhost hosts by default.
In the E2E Docker Compose stack, Playwright accesses the frontend via
http://frontend (container hostname). Without allowedHosts, Vite returns
"Blocked request. This host is not allowed." and the SPA never mounts,
causing all 59 E2E tests to fail with blank pages and missing elements.
- Add preview.host: true (bind to 0.0.0.0)
- Add preview.allowedHosts: ['frontend', 'localhost']
test: update payment-redirect E2E tests to match current UI
The payment page was redesigned to a two-step confirmation flow:
"Jag har betalat" → confirmation → "Ja, jag har betalat". The E2E
tests still referenced the old single-step "Genomför testbetalning"
button and a removed .payment__note CSS class.
- Update 'payment button marks order as paid' to click through both steps
- Rename 'shows mock payment note' to 'shows Swish payment instructions'
and assert on actual UI elements (Swish label + payment button)
Result: E2E suite now passes 59/59 tests in the Docker Compose CI stack.
Replace the mock test-payment button with a real manual Swish flow
where the user sends a Swish payment with the order ID as message
and confirms via a button. Admin verifies Swish and processes manually.
Backend
- Rename OrderStatus LOOKUP_STARTED to PROCESSING (Swedish: Hanteras)
- Update V5 migration CHECK constraint from lookup_started to processing
- Rename OrderService.markAsPaid() to confirmPayment(), sets PROCESSING
instead of PAID, stop hardcoding amountPaid
- Add GET /api/payment/swish-info endpoint returning swish number and
letter price from app.payment config
- Permit /api/payment/swish-info without authentication
- Update UpdateStatusRequest regex to accept processing
- Update PaymentControllerTest for renamed method, new status, and
public swish-info endpoint test
Frontend
- Rewrite PaymentRedirect.vue: Swish number, order ID as message,
Jag har betalat button with confirmation dialog
- Add fetchSwishInfo() to api/payment.ts
- AdminPage: rename Skickade stat to Att göra (processing orders),
highlight processing rows with admin__row--todo
- OrdersPage: update status labels/badge classes for new flow
- Refactor ApiError in client.ts to property declaration syntax
- Exclude __tests__ from tsconfig.app.json and Docker builds
Tests
- Rewrite PaymentRedirect.spec.ts for Swish info, confirmation dialog,
cancel flow, and processing status
- Update OrdersPage.spec.ts with processing status test
- Update AdminDashboard.spec.ts with Att göra stat and row highlight
- Add amountPaid to ComposePage.spec.ts mock
Config
- Add SWISH_NUMBER to .env.example and docker-compose.yml
Three problems caused E2E browser tests to fail in Forgejo CI:
1. TypeScript build errors in (frontend.e2e.Dockerfile):
- used parameter property which violates
. Replaced with explicit property declaration.
- included in type-checking, causing
mock Response type mismatches. Added .
- mock Order was missing field.
2. Nginx SSL crash:
- copied production
which references SSL certs that don't exist in the e2e image.
- Replaced nginx entirely with (simpler, no SSL needed).
- Added to so routes to backend.
3. Docker context hygiene:
- excludes so test files don't
bloat the build context or trigger type errors in the container.
All other files untouched.
The per-job DinD approach failed because Forgejo Runner's service container
DNS resolution does not work when the runner itself uses DinD
(container.docker_host: tcp://dind:2375). The job container could not resolve
the 'dind' service hostname, causing docker compose to fail immediately.
New approach:
- Runner now uses container.docker_host: 'automount' which mounts the host
Docker socket into job containers. The runner runs as root (user: 0:0)
to access /var/run/docker.sock.
- E2E job no longer uses a 'dind' service. docker compose runs directly
against the host Docker daemon inside the job container.
- docker-compose.e2e.yml gets a custom 'e2e' bridge network. All E2E
containers (postgres, backend, frontend, playwright) attach only to this
network, isolating them from other host containers (Nextcloud, Jellyfin,
etc.). They can still reach the internet for vehicle lookup and npm.
Tradeoff: job containers can see other containers via docker ps, but they
are on an isolated network. For a single-user home server, this is the
simplest reliable configuration.
Implement per-job Docker-in-Docker (DinD) for E2E tests, giving each
job a completely isolated Docker daemon and network. This prevents
leakage to the host Docker or other containers.
The previous E2E approach failed because:
1. The Forgejo runner's container.docker_host was not set, causing
the runner itself to try unix:///var/run/docker.sock and crash-loop.
2. The host DinD daemon had isolated networking — job containers
running docker compose could not resolve 'dind' hostname or access
host filesystem bind mounts (e.g. .:/app).
New approach — zero bind mounts, all COPY-based images:
- docker/backend.e2e.Dockerfile: multi-stage build from repo root.
Copies gradlew + settings.gradle + backend/build.gradle to download
dependencies in a cacheable layer, then copies backend/src and builds
the bootJar. Runs the JAR directly on startup.
- docker/frontend.e2e.Dockerfile: multi-stage Node build → nginx.
Reuses existing docker/nginx.conf for /api proxy to backend service.
No volume mounts, fully self-contained.
- docker/playwright.e2e.Dockerfile: extends official Playwright image.
Installs deps from package-lock.json, copies e2e tests + config.
- docker-compose.e2e.yml: zero bind mounts. Services depend on each
other in order: postgres (healthy) → backend → frontend → playwright.
Playwright waits for backend and frontend via curl loops before
running tests.
- .forgejo/workflows/ci.yml: E2E job adds a 'dind' service container
(docker:28-dind, privileged, no TLS). The job sets DOCKER_HOST to
tcp://dind:2375 so the docker CLI inside the job talks to the
per-job DinD daemon. The compose file is docker-compose.e2e.yml.
- Runner fix on tocke: added container.docker_host: 'tcp://dind:2375'
to runner-config.yaml so the runner's own Docker client connects to
the host DinD container, stopping the crash loop.
Key properties:
- Network isolation: each E2E job gets its own DinD with its own
container network. No host container visibility.
- No bind mount leakage: all images use COPY instead of volume mounts.
The per-job DinD has its own filesystem and can't see host paths.
- Deterministic: builds start from clean state every time. Image cache
exists only within the per-job DinD lifetime.
- Lint-and-test job is untouched and remains green.
- Backend coverage runs from repo root where gradlew lives
- Frontend coverage runs from frontend/ with working-directory
- No cd tricks that break relative paths
- Remove working-directory: frontend from coverage step
- cd back to repo root for ./gradlew command, then cd frontend for npm
- Gradle wrapper lives at repo root, not in frontend/
- Rename FORGEJO_SERVER_URL to GITHUB_SERVER_URL
- The actions/checkout action reads GITHUB_SERVER_URL to construct the
clone URL. The runner was cloning https://srvr.nu/jocke/bilhej/ instead
of https://srvr.nu/git/jocke/bilhej/ because the /git/ subpath was lost