# BilHej / Bilhälsning.se Send a physical letter to a Swedish car owner — just by knowing their license plate. The user enters a registration number, composes a letter (from a template or free text), pays, and BilHej handles the rest: owner address lookup via Transportstyrelsen, printing and mailing via PostNord. The sender never sees the recipient's name or address. --- ## Tech Stack | Layer | Technology | |-------------|-----------------------------------------| | Frontend | Vue.js 3 (Composition API), Vite, Pinia | | Backend | Java 21, Spring Boot 4, Gradle | | Database | PostgreSQL 16 | | Auth | Spring Security + JWT | | Payments | Stripe (cards + Swish) | | Deployment | Docker, Docker Compose | --- ## Prerequisites - Docker & Docker Compose - Java 21 (for local IDE development) - Node.js 20+ (for local frontend dev) - A [Stripe](https://stripe.com) account (test mode for development) --- ## Quick Start ```bash git clone bilhej cd bilhej cp .env.example .env # fill in your keys docker compose up -d # or: ./gradlew up ``` The app will be available at: - Frontend: `http://localhost:3000` - Backend API: `http://localhost:8080` - PostgreSQL: `localhost:5432` - Mailpit (dev SMTP inbox): `http://localhost:8025` ### Architecture inside Docker Compose ``` Browser Docker Compose network ─────── ───────────────────── │ ┌──────────────────┐ │ http://localhost:3000 │ frontend (Vite) │ ├────────────────────────→│ :3000 │ │ │ proxy: /api → │ │ GET /api/orders │ backend:8080 │ │ └────────┬─────────┘ │ │ │ ┌────────▼─────────┐ │ │ backend (Spring) │ │ │ :8080 │ │ │ profile: docker │ │ └────────┬─────────┘ │ │ │ ┌────────▼─────────┐ │ │ postgres (16) │ │ │ :5432 │ │ └──────────────────┘ │ ┌──────────────────┐ │ │ mailpit │ │ │ SMTP :1025 │ │ │ UI :8025 │ │ └──────────────────┘ ``` **Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container. No CORS configuration needed in development — the browser never calls the backend directly. **Spring profiles:** | Profile | Datasource | Use | |---------|-----------|-----| | default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) | | `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI | | `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) | --- ## Environment Variables Copy `.env.example` to `.env` and fill in: | Variable | Description | |---------------------------|--------------------------------------| | `POSTGRES_DB` | Database name (default: `bilhej`) | | `POSTGRES_USER` | Database user | | `POSTGRES_PASSWORD` | Database password | | `JWT_SECRET` | Secret key for JWT signing | | `STRIPE_SECRET_KEY` | Stripe secret key | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | | `STRIPE_PRICE_ID` | Stripe price ID for single letter | | `SWISH_NUMBER` | Swish number for payment instructions | | `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) | | `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) | | `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) | | `MAIL_USERNAME` | SMTP username (empty for Mailpit) | | `MAIL_PASSWORD` | SMTP password (empty for Mailpit) | | `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) | | `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) | | `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) | **Dev-only accounts** (from `db/dev-migration`, not used in production): | Email | Password | Role | |-------|----------|------| | `test@bilhej.se` | `test1234` | user (e2e / local) | | `admin@bilhalsning.se` | `test1234` | admin (local docker only) | --- ## Database access PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus, pgAdmin) — same idea as a normal remote database. ### Local dev (`docker compose up`) | Setting | Value | |---------|--------| | Host | `localhost` | | Port | `5432` | | Database | from `.env` → `POSTGRES_DB` (default `bilhej`) | | User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` | **IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above → Test Connection → OK. **CLI:** ```bash docker exec -it bilhej-postgres psql -U bilhej -d bilhej ``` ### Production (server) Postgres is bound to **localhost only** on the server (`127.0.0.1:5433`) so it is not exposed to the internet. Use an **SSH tunnel** from your laptop, then point IntelliJ (or DBeaver) at `localhost`. 1. On the server, recreate the stack once so the port mapping is active (after deploy). 2. From your laptop: ```bash ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu ``` 3. In IntelliJ / DBeaver: | Setting | Value | |---------|--------| | Host | `localhost` | | Port | `5433` | | Database | prod `POSTGRES_DB` | | User / password | prod secrets | **CLI on the server** (no GUI): ```bash docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej ``` ### Manual prod cleanup (keep data, remove dev seeds) ```sql DELETE FROM orders WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; DELETE FROM users WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se'); ``` Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production admin on startup. No need to insert a password hash by hand. ### Email (password reset) The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on the server unless you want human addresses like `support@bilhej.se`. **Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/) (`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet. If `docker compose pull` fails on Docker Hub, pull explicitly: ```bash docker pull ghcr.io/axllent/mailpit:v1.28 ``` 1. Open **http://localhost:8025** 2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in) 3. Open the message in Mailpit and click the reset link To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in `docker-compose.yml` or set `MAIL_HOST=` in `.env`. **Production (transactional provider):** Use [Resend](https://resend.com) or [Brevo](https://www.brevo.com)—not a self-hosted mail server on the VPS. 1. Sign up and add domain **bilhej.se** 2. Add the provider’s **SPF** and **DKIM** DNS records at your registrar (no MX needed for send-only) 3. Create SMTP credentials in the provider dashboard 4. On the production server `.env` (or Forgejo deploy secrets): ```bash APP_PUBLIC_BASE_URL=https://bilhej.se MAIL_HOST=smtp.resend.com # example; use your provider’s host MAIL_PORT=587 MAIL_USERNAME=resend # example MAIL_PASSWORD=re_xxxxxxxx MAIL_FROM=noreply@bilhej.se ``` 5. Deploy via **Deploy to Production**, then test forgot-password on https://bilhej.se See [docs/production-email-checklist.md](docs/production-email-checklist.md) for a step-by-step operator checklist. If SMTP is not configured (`MAIL_HOST` empty), the reset link is written to the backend log: ```bash docker logs bilhej-backend-prod 2>&1 | grep "Password reset link" ``` Optional later: real inboxes (`support@bilhej.se`) via Migadu, Fastmail, or Purelymail—separate from app `MAIL_*` (different MX records). To generate a bcrypt hash manually (optional): ```bash ./gradlew hashPassword -Ppassword='your-strong-password' ``` --- ## Project Structure ``` bilhej/ ├── frontend/ # Vue.js 3 SPA │ ├── src/ │ │ ├── components/ # Reusable UI components │ │ ├── composables/ # Shared composition functions │ │ ├── layouts/ # Page layouts │ │ ├── pages/ # Route-level page components │ │ ├── router/ # Vue Router config │ │ ├── stores/ # Pinia stores │ │ ├── api/ # API client and endpoints │ │ ├── assets/ # Static assets, CSS │ │ ├── App.vue │ │ └── main.ts │ ├── index.html │ ├── vite.config.ts │ └── package.json ├── backend/ # Spring Boot 4 │ ├── src/main/java/se/bilhalsning/ │ │ ├── BilHejApplication.java │ │ ├── config/ # Security, CORS, Stripe config │ │ ├── controller/ # REST controllers │ │ ├── dto/ # Data transfer objects │ │ ├── entity/ # JPA entities │ │ ├── repository/ # Spring Data repositories │ │ ├── service/ # Business logic │ │ └── security/ # JWT filter, user details │ └── src/main/resources/ │ ├── application.yml # default profile (H2) │ ├── application-docker.yml # docker profile (PostgreSQL) │ └── db/migration/ # Flyway migrations ├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR) ├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped ├── docker/ │ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun │ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root) │ ├── frontend.Dockerfile # dev: Node + vite dev server │ ├── frontend.prod.Dockerfile # prod: multi-stage (Node → nginx) │ ├── nginx.conf # prod: SPA fallback + /api proxy │ └── entrypoint.sh # prod: self-signed cert generation ├── gradlew # Gradle wrapper (run from repo root) ├── gradle/ │ └── wrapper/ ├── settings.gradle # rootProject.name + include 'backend' ├── build.gradle # convenience tasks: check, up, down, reset ├── .env.example ├── README.md ├── REQUIREMENTS.md └── CODING_GUIDELINES.md ``` --- ## Development vs Production | Aspect | `docker compose up -d` | `docker compose -f docker-compose.prod.yml up -d` | |--------|------------------------|---------------------------------------------------| | Backend | `./gradlew :backend:bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` | | Backend image | `eclipse-temurin:21-jdk` (~400 MB) | `eclipse-temurin:21-jre-alpine` (~200 MB) | | Backend user | root | `bilhej` (non-root) | | Frontend | Vite dev server (HMR, `--host 0.0.0.0`) | nginx serving static `dist/` | | API proxy | Vite built-in proxy (`/api` → `backend:8080`) | nginx `proxy_pass` | | SSL | None | Self-signed cert auto-generated on first start (`.certs/` volume) | | Source mounts | Yes (live edit) | No (files baked into image) | | Restart policy | Manual | `unless-stopped` | | Purpose | Write code, instant feedback | Verify production build | --- ## Production Deployment Deployments are fully automated via Forgejo Actions. The pipeline builds production Docker images and starts them on the server. ### One-time Setup Before the first deploy, complete these steps on the production server (`srvr.nu`): 1. **Add Forgejo Actions Secrets** Go to **Forgejo → Repository Settings → Actions → Secrets** and add: | Secret | Description | |--------|-------------| | `POSTGRES_DB` | Database name (e.g., `bilhej`) | | `POSTGRES_USER` | Database user | | `POSTGRES_PASSWORD` | Strong database password | | `JWT_SECRET` | `openssl rand -hex 32` | | `STRIPE_SECRET_KEY` | Stripe secret key | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | | `STRIPE_PRICE_ID` | Stripe price ID for single letter | | `SWISH_NUMBER` | Swish phone number for payment instructions | | `ADMIN_EMAIL` | Production admin email (e.g. `admin@bilhej.se`) | | `ADMIN_PASSWORD` | Strong unique admin password (password manager) | Passwords may contain `$` — the deploy workflow escapes these for Docker Compose. Production does **not** seed `test@bilhej.se` or demo orders. On first start, the backend creates one admin from `ADMIN_EMAIL` / `ADMIN_PASSWORD` if no admin exists. If prod already has dev seed users, clean them with SQL (see [Database access](#database-access)) instead of wiping the volume. Then redeploy with the new secrets so bootstrap can create `ADMIN_EMAIL`. 2. **Point DNS** Set `bilhej.se` (and `www.bilhej.se`) A record to the server's public IP. 3. **Add HTTP-only Nginx vhost** (required before certs exist) The full [`docker/bilhej.nginx.conf`](docker/bilhej.nginx.conf) references TLS files that do not exist yet. Deploy the HTTP-only config first: ```bash docker cp docker/bilhej.nginx.http.conf nginx:/etc/nginx/conf.d/bilhej.conf docker exec nginx nginx -t docker exec nginx nginx -s reload ``` 4. **Obtain SSL Certificate** ```bash docker exec certbot certbot certonly \ --webroot -w /var/www/certbot \ -d bilhej.se -d www.bilhej.se ``` 5. **Enable HTTPS proxy to the frontend** ```bash docker cp docker/bilhej.nginx.conf nginx:/etc/nginx/conf.d/bilhej.conf docker exec nginx nginx -t docker exec nginx nginx -s reload ``` ### Deploy 1. Go to **Actions → Deploy to Production** in Forgejo. 2. Click **Run workflow**. 3. Enter a version tag (e.g., `v0.1.0`). 4. Click **Run workflow**. ### Deploy failed (backend health check) If the job passes the frontend check but the backend never becomes healthy: 1. Open the failed job log and read **Backend logs** (printed before rollback). 2. Match the error to a fix — do not guess: - **`Detected applied migration not resolved locally: 6`** (or 2, 4) — prod DB still lists dev seed migrations removed from `db/migration`. Fixed in app via `ProdFlywayConfig` (repair before migrate); redeploy after that commit is on `master`. - **`password authentication failed`** — DB credentials in the running stack do not match what Postgres was initialized with; fix credentials or Postgres password to match (only wipe the volume if you accept losing prod data). - **`Production requires ADMIN_EMAIL and ADMIN_PASSWORD`** — add those Forgejo secrets. - **Other Flyway / migration errors** — read the stack trace; do not wipe the volume unless the log clearly requires it. 3. **DBeaver from your laptop** — prod Postgres binds to `127.0.0.1:5433` on the server only. Use an SSH tunnel, then host `localhost` port `5433` (not `192.168.0.59` directly). ### What Happens | Step | Action | |------|--------| | Tag | Git tag `v0.1.0` is created and pushed | | Build | Production backend JAR and frontend bundle are built | | Images | Multi-stage Docker images are built locally on the server | | Start | `docker compose -f docker-compose.prod.yml up -d` (`SPRING_PROFILES_ACTIVE=docker,prod`) | | Verify | Health checks confirm backend API and frontend are responding | ### Architecture on Server ``` User │ │ https://bilhej.se ▼ ┌─────────────────────────────────────┐ │ nginx (srvr.nu) │ │ SSL termination (Let's Encrypt) │ │ proxy_pass → bilhej-frontend-prod │ └─────────────┬───────────────────────┘ │ Docker 'web' network ┌─────────────▼───────────────────────┐ │ bilhej-frontend-prod (nginx) │ │ :80 │ │ /api/* → bilhej-backend-prod:8080 │ └─────────────┬───────────────────────┘ │ ┌─────────────▼───────────────────────┐ │ bilhej-backend-prod (Spring Boot) │ │ :8080 │ └─────────────┬───────────────────────┘ │ ┌─────────────▼───────────────────────┐ │ bilhej-postgres-prod (PostgreSQL) │ │ :5432 │ └─────────────────────────────────────┘ ``` ### Rollback To rollback to a previous version: ```bash # On srvr.nu cd /path/to/bilhej/repo git fetch --tags git checkout v0.1.0 # or any previous tag docker compose -f docker-compose.prod.yml up --build -d ``` --- ## Development ### All-in-one (from repo root) ```bash ./gradlew check # lint → frontend test → backend test → integration test ./gradlew up # docker compose up -d ./gradlew down # docker compose down ./gradlew reset # docker compose down -v && docker compose up -d (full DB reset) ``` ### Frontend (dev server with HMR) ```bash cd frontend npm install # first time only npm run dev # :3000 with HMR ``` ### Backend (IDE or CLI) ```bash ./gradlew :backend:bootRun # :8080, profile: default (H2) ``` ### Stripe Webhooks (local testing) ```bash stripe listen --forward-to localhost:8080/api/webhooks/stripe ``` ### Database reset ```bash ./gradlew reset # wipes DB volume and restarts containers ``` --- ## Related Documents - [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model - [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards