Compare commits

..

No commits in common. "d7739bcd58885f0247486d9671c5ca233bdbd6fd" and "f2c1a9e2d61751bbf113ab9f2ecda2fcab1e03fc" have entirely different histories.

256 changed files with 95 additions and 28022 deletions

View file

@ -1,66 +0,0 @@
# Exclude everything that isn't strictly needed to build or run the dev images.
# The dev Dockerfiles COPY source subpaths (frontend/, backend/, gradlew,
# settings.gradle, etc.), so without this the image would bloat with docs,
# scripts, git history, etc.
# Build artifacts and caches (mounted as named volumes at runtime)
.gradle
backend/build
frontend/dist
frontend/coverage
frontend/node_modules
backend/.gradle
# Test outputs
**/build/test-results
**/build/reports
**/coverage
**/.pytest_cache
frontend/playwright-report
frontend/test-results
# Local config and secrets
.env
.env.*
!.env.example
**/application-local.yml
# VCS and editor state
.git
.gitignore
.gitattributes
.github
.forgejo
.idea
.vscode
*.iml
.DS_Store
# Documentation (not needed at runtime)
README.md
REQUIREMENTS.md
AGENTS.md
CODING_GUIDELINES.md
docs/
# Ops scripts (not needed at runtime)
scripts/
# Test source dirs that aren't built into runtime artifacts
frontend/src/__tests__
backend/src/test
# Docker metadata — Dockerfiles, .dockerignore, and compose files are not
# needed inside the running image. Keep docker/*.conf and docker/entrypoint.sh
# because frontend.prod.Dockerfile copies them into the production nginx image.
docker/*.Dockerfile
Dockerfile*
.dockerignore
docker-compose*.yml
# Misc
*.log
logs/
tmp/
*.bak
*.tmp

View file

@ -1,52 +0,0 @@
# BilHej Environment Variables
# Copy this file to .env and fill in your keys.
#
# cp .env.example .env
#
# Docker Compose reads .env from the project root automatically.
# ---------- PostgreSQL ----------
POSTGRES_DB=bilhej
POSTGRES_USER=bilhej
POSTGRES_PASSWORD=change_me
# ---------- JWT ----------
# Generate a secure random secret:
# openssl rand -hex 32
JWT_SECRET=change_me_to_a_random_64_char_string
# ---------- Stripe (Phase 1) ----------
# Test keys from Stripe Dashboard: https://dashboard.stripe.com/test/apikeys
STRIPE_SECRET_KEY=sk_test_...
# Webhook secret from stripe CLI: stripe listen --print-secret
STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567
# ---------- App URL (password reset links in email) ----------
APP_PUBLIC_BASE_URL=http://localhost:3000
# ---------- SMTP (local Docker uses Mailpit via docker-compose.yml) ----------
# docker compose up → view mail at http://localhost:8025
# Leave MAIL_HOST unset in .env to use compose defaults (mailpit).
# Production (Resend SMTP) — see docs/production-email-checklist.md
# MAIL_HOST=smtp.resend.com
# MAIL_PORT=587
# MAIL_USERNAME=resend
# MAIL_PASSWORD=re_... # API key; never commit a real value
# MAIL_FROM=noreply@bilhej.se
# ---------- Production admin (prod profile only) ----------
# Strong password; never use test1234. Dev seeds use test@bilhej.se instead.
ADMIN_EMAIL=admin@bilhej.se
ADMIN_PASSWORD=change_me_to_a_strong_password
# ---------- Umami analytics (production frontend build only) ----------
# Baked into the frontend image at build time. Leave unset for local dev / docker compose up.
# Website ID from https://analytics.bilhej.se → Settings → Websites → BilHej
# See docs/umami-analytics.md
# VITE_UMAMI_WEBSITE_ID=
# VITE_UMAMI_SCRIPT_URL=https://analytics.bilhej.se/script.js

View file

@ -1,123 +0,0 @@
name: CI
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
jobs:
lint-and-test:
name: Lint, type check, unit tests, coverage
runs-on: ubuntu-latest
steps:
- name: Checkout repository
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
git fetch --depth 1 origin master
- name: Check Flyway migrations
run: bash scripts/check-flyway-migrations.sh origin/master
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend
- name: Type check
run: npx vue-tsc --noEmit
working-directory: frontend
- name: Backend coverage
run: ./gradlew :backend:jacocoTestCoverageVerification
- name: Print coverage summary
run: |
node -e "
const fs = require('fs');
const xml = fs.readFileSync('backend/build/reports/jacoco/test/jacocoTestReport.xml', 'utf8');
const lineMatch = xml.match(/<counter type=\"LINE\" missed=\"(\d+)\" covered=\"(\d+)\"\\/>/g);
const branchMatch = xml.match(/<counter type=\"BRANCH\" missed=\"(\d+)\" covered=\"(\d+)\"\\/>/g);
if (lineMatch && branchMatch) {
const lastLine = lineMatch[lineMatch.length - 1].match(/missed=\"(\d+)\" covered=\"(\d+)\"/);
const lastBranch = branchMatch[branchMatch.length - 1].match(/missed=\"(\d+)\" covered=\"(\d+)\"/);
const lineMissed = +lastLine[1], lineCovered = +lastLine[2];
const branchMissed = +lastBranch[1], branchCovered = +lastBranch[2];
const linePct = (lineCovered / (lineMissed + lineCovered) * 100).toFixed(1);
const branchPct = (branchCovered / (branchMissed + branchCovered) * 100).toFixed(1);
const allPass = linePct >= 70 && branchPct >= 60;
console.log('');
console.log('╔════════════════╦══════════╦════════════╗');
console.log('║ Coverage Summary — Bilhej ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Layer │ Lines │ Branch ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Backend │ ' + (linePct + '%').padStart(7) + ' │ ' + (branchPct + '%').padStart(7) + ' ║');
console.log('╠════════════════╬══════════╬════════════╣');
console.log('║ Thresholds │ ' + '70.0%'.padStart(7) + ' │ ' + '60.0%'.padStart(7) + ' ║');
console.log('╚════════════════╩══════════╩════════════╝');
console.log(allPass ? 'All backend thresholds met.' : 'Some backend thresholds missed.');
console.log('');
console.log('Frontend coverage printed above by Vitest text reporter.');
console.log('Download full HTML reports from the Artifacts tab.');
console.log('');
}
"
- name: Frontend coverage
run: npm run test:coverage
working-directory: frontend
- name: Upload backend coverage report
uses: actions/upload-artifact@v3
with:
name: backend-coverage
path: backend/build/reports/jacoco/test/html/
retention-days: 7
- name: Upload frontend coverage report
uses: actions/upload-artifact@v3
with:
name: frontend-coverage
path: frontend/coverage/
retention-days: 7
e2e:
name: E2E browser tests
runs-on: ubuntu-latest
env:
POSTGRES_DB: bilhej
POSTGRES_USER: bilhej
POSTGRES_PASSWORD: test_pw_ci_123
JWT_SECRET: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
STRIPE_SECRET_KEY: sk_test_fake
STRIPE_WEBHOOK_SECRET: whsec_fake
STRIPE_PRICE_ID: price_fake
steps:
- name: Checkout repository
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Run E2E test stack
run: |
docker compose \
-f docker-compose.e2e.yml \
up --build --abort-on-container-exit --exit-code-from playwright

View file

@ -1,144 +0,0 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
version:
description: 'Git tag to create for this deploy (e.g. v0.1.2) — not the branch/tag above'
required: true
default: 'v0.1.0'
type: string
jobs:
deploy:
name: Build and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout repository
run: |
git init
git remote add origin https://x-access-token:${FORGEJO_TOKEN}@srvr.nu/git/jocke/bilhej.git
git fetch --depth 1 origin ${GITHUB_SHA}
git checkout FETCH_HEAD
- name: Tag version
run: |
git tag -d ${{ github.event.inputs.version }} 2>/dev/null || true
git push origin --delete ${{ github.event.inputs.version }} 2>/dev/null || true
git tag ${{ github.event.inputs.version }}
git push origin ${{ github.event.inputs.version }}
- name: Write production .env
env:
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PRICE_ID: ${{ secrets.STRIPE_PRICE_ID }}
SWISH_NUMBER: ${{ secrets.SWISH_NUMBER }}
ADMIN_EMAIL: ${{ secrets.ADMIN_EMAIL }}
ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
APP_PUBLIC_BASE_URL: ${{ secrets.APP_PUBLIC_BASE_URL }}
MAIL_HOST: ${{ secrets.MAIL_HOST }}
MAIL_PORT: ${{ secrets.MAIL_PORT }}
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
VITE_UMAMI_WEBSITE_ID: ${{ secrets.VITE_UMAMI_WEBSITE_ID }}
run: |
# Docker Compose treats $ as variable interpolation in .env files.
# Escape literal dollar signs (e.g. in passwords) as $$.
escape() { printf '%s' "$1" | sed 's/\$/$$/g'; }
{
printf 'POSTGRES_DB=%s\n' "$(escape "$POSTGRES_DB")"
printf 'POSTGRES_USER=%s\n' "$(escape "$POSTGRES_USER")"
printf 'POSTGRES_PASSWORD=%s\n' "$(escape "$POSTGRES_PASSWORD")"
printf 'JWT_SECRET=%s\n' "$(escape "$JWT_SECRET")"
printf 'STRIPE_SECRET_KEY=%s\n' "$(escape "$STRIPE_SECRET_KEY")"
printf 'STRIPE_WEBHOOK_SECRET=%s\n' "$(escape "$STRIPE_WEBHOOK_SECRET")"
printf 'STRIPE_PRICE_ID=%s\n' "$(escape "$STRIPE_PRICE_ID")"
printf 'SWISH_NUMBER=%s\n' "$(escape "$SWISH_NUMBER")"
printf 'ADMIN_EMAIL=%s\n' "$(escape "$ADMIN_EMAIL")"
printf 'ADMIN_PASSWORD=%s\n' "$(escape "$ADMIN_PASSWORD")"
printf 'APP_PUBLIC_BASE_URL=%s\n' "$(escape "${APP_PUBLIC_BASE_URL:-https://bilhej.se}")"
printf 'MAIL_HOST=%s\n' "$(escape "$MAIL_HOST")"
printf 'MAIL_PORT=%s\n' "$(escape "${MAIL_PORT:-587}")"
printf 'MAIL_USERNAME=%s\n' "$(escape "$MAIL_USERNAME")"
printf 'MAIL_PASSWORD=%s\n' "$(escape "$MAIL_PASSWORD")"
printf 'MAIL_FROM=%s\n' "$(escape "${MAIL_FROM:-noreply@bilhej.se}")"
printf 'VITE_UMAMI_WEBSITE_ID=%s\n' "$(escape "$VITE_UMAMI_WEBSITE_ID")"
} > .env
- name: Build and start production stack
run: |
docker compose -p bilhej-prod -f docker-compose.prod.yml down
docker compose -p bilhej-prod -f docker-compose.prod.yml up --build -d
- name: Health checks with rollback
run: |
echo "Waiting for services to start..."
sleep 30
BACKEND_OK=false
for i in 1 2 3 4 5 6 7 8 9 10; do
if docker run --rm --network bilhej-prod_default curlimages/curl:8.5.0 \
-sf http://bilhej-backend-prod:8080/api/payment/swish-info > /dev/null; then
echo "Backend is healthy"
BACKEND_OK=true
break
fi
echo "Backend check attempt $i failed, retrying in 5s..."
sleep 5
done
FRONTEND_OK=false
for i in 1 2 3 4 5; do
if docker run --rm --network bilhej-prod_default curlimages/curl:8.5.0 \
-s http://bilhej-frontend-prod/ | grep -qi "bilhej\|Bilhej\|BilHej"; then
echo "Frontend is serving"
FRONTEND_OK=true
break
fi
echo "Frontend check attempt $i failed, retrying in 5s..."
sleep 5
done
if [ "$BACKEND_OK" != "true" ] || [ "$FRONTEND_OK" != "true" ]; then
echo ""
echo "═══════════════════════════════════════════════════"
echo " HEALTH CHECK FAILED — DIAGNOSTICS"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
echo ""
echo "--- Backend logs ---"
docker logs bilhej-backend-prod 2>&1 | tail -80 || true
echo ""
echo "--- Postgres logs ---"
docker logs bilhej-postgres-prod 2>&1 | tail -30 || true
echo ""
echo "═══════════════════════════════════════════════════"
echo " ROLLING BACK DEPLOYMENT"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml down
echo ""
echo "Rolled back. Containers stopped. DB volume preserved."
echo "Read Backend logs above to find the root cause before redeploying."
exit 1
fi
- name: Print deploy status
run: |
echo ""
echo "═══════════════════════════════════════════════════"
echo " Deployed ${{ github.event.inputs.version }} to production"
echo "═══════════════════════════════════════════════════"
echo ""
docker compose -p bilhej-prod -f docker-compose.prod.yml ps
echo ""
echo "Containers running. Update nginx config on srvr.nu"
echo "to point bilhej.se to the frontend container."
echo ""

6
.gitignore vendored
View file

@ -10,7 +10,6 @@ target/
*.jar *.jar
*.war *.war
!.mvn/wrapper/maven-wrapper.jar !.mvn/wrapper/maven-wrapper.jar
!gradle/wrapper/gradle-wrapper.jar
# Environment # Environment
.env .env
@ -38,11 +37,6 @@ Thumbs.db
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
certs/
# Gradle
.gradle/
build/
# Java # Java
*.hprof *.hprof

158
AGENTS.md
View file

@ -15,7 +15,7 @@ the recipient's name or address.
integration yet. Owner address is obtained manually by a human and entered into integration yet. Owner address is obtained manually by a human and entered into
the admin panel. the admin panel.
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend + Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 3 backend +
PostgreSQL 16. Deployed via Docker Compose. PostgreSQL 16. Deployed via Docker Compose.
--- ---
@ -24,24 +24,11 @@ PostgreSQL 16. Deployed via Docker Compose.
Always run these after making changes to verify nothing is broken. Always run these after making changes to verify nothing is broken.
Gradle lives at repo root. All commands below run from the repo root unless noted.
### Quick start (everything) ### Quick start (everything)
```bash ```bash
cp .env.example .env # first time only, then fill in keys cp .env.example .env # first time only, then fill in keys
docker compose up -d # starts postgres, backend, frontend docker compose up -d # starts postgres, backend, frontend
./gradlew up # same as above (Gradle wrapper)
```
### All-in-one
```bash
./gradlew check # frontend lint → frontend test → backend test+coverage → E2E (Docker)
./gradlew coverage # backend + frontend tests with coverage reports
./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 (Vue.js 3 + Vite) ### Frontend (Vue.js 3 + Vite)
@ -53,14 +40,15 @@ npm run dev # dev server on :3000 with HMR
npm run build # production build npm run build # production build
npm run lint # ESLint npm run lint # ESLint
npm run test # vitest npm run test # vitest
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
``` ```
### Backend (Spring Boot 4 + Java 21) ### Backend (Spring Boot 3 + Java 21)
```bash ```bash
./gradlew :backend:bootRun # dev server on :8080 cd backend
./gradlew :backend:test # JUnit 5 + Mockito (backend only) ./mvnw spring-boot:run # dev server on :8080
./mvnw test # JUnit 5 + Mockito
./mvnw verify # full verification including integration tests
``` ```
### Stripe webhooks (local testing) ### Stripe webhooks (local testing)
@ -74,20 +62,8 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
Flyway migrations run automatically on Spring Boot startup. Migration files Flyway migrations run automatically on Spring Boot startup. Migration files
live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`. live in `backend/src/main/resources/db/migration/`. Naming: `V<number>__descriptive_name.sql`.
**Before adding a migration:** run `./scripts/next-flyway-version.sh` and use that
version. Never reuse a version number already on `master`. Never edit a migration
after it has merged — add a new higher version instead. CI runs
`scripts/check-flyway-migrations.sh` against `origin/master`.
If local dev Postgres fails with Flyway checksum / “migration not resolved locally”
after switching branches, run `./gradlew reset` (wipes the Docker DB volume).
To reset: `docker compose down -v && docker compose up -d`. To reset: `docker compose down -v && docker compose up -d`.
Flyway schema migrations live in `db/migration/`; dev-only seeds (test users,
sample orders) are in `db/dev-migration/` and run only without the `prod` profile.
Production admin is created from `ADMIN_EMAIL` / `ADMIN_PASSWORD` on first boot.
--- ---
## Project Structure ## Project Structure
@ -98,14 +74,13 @@ bilhej/
│ ├── src/ │ ├── src/
│ │ ├── pages/ # Route-level page components │ │ ├── pages/ # Route-level page components
│ │ ├── components/ # Reusable UI components │ │ ├── components/ # Reusable UI components
│ │ ├── composables/ # useXxx.ts shared logic │ │ ├── composables/ # useXxx.js shared logic
│ │ ├── stores/ # Pinia stores │ │ ├── stores/ # Pinia stores
│ │ ├── api/ # API client modules │ │ ├── api/ # API client modules
│ │ ├── router/ # Vue Router config │ │ ├── router/ # Vue Router config
│ │ └── assets/ # Static files, CSS │ │ └── assets/ # Static files, CSS
│ └── ... │ └── ...
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject ├── backend/ # Spring Boot 3 (Java 21)
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
│ ├── src/main/java/se/bilhalsning/ │ ├── src/main/java/se/bilhalsning/
│ │ ├── config/ # @Configuration classes │ │ ├── config/ # @Configuration classes
│ │ ├── controller/ # REST controllers │ │ ├── controller/ # REST controllers
@ -117,23 +92,11 @@ bilhej/
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice │ │ ├── exception/ # Custom exceptions + @ControllerAdvice
│ │ └── mapper/ # Entity ↔ DTO mapping │ │ └── mapper/ # Entity ↔ DTO mapping
│ └── src/main/resources/ │ └── src/main/resources/
│ ├── application.yml # default (H2, IDE dev) │ ├── application.yml
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations │ └── db/migration/ # Flyway migrations
├── docker/ # Dockerfiles ├── docker/ # Dockerfiles
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun ├── docker-compose.yml
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root) ├── docker-compose.prod.yml
│ ├── frontend.Dockerfile # dev: Node 24 + vite dev server
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node build → nginx)
│ ├── nginx.conf # prod: SPA fallback + /api reverse proxy
│ └── entrypoint.sh # prod: self-signed cert generation
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
├── docker-compose.prod.yml # prod: multi-stage images, no source mounts, restart always
├── gradlew # Gradle wrapper (repo root)
├── gradle/
│ └── wrapper/
├── settings.gradle # rootProject.name + include 'backend'
├── build.gradle # convenience tasks: check, up, down, reset
├── .env.example ├── .env.example
├── AGENTS.md # This file ├── AGENTS.md # This file
├── README.md ├── README.md
@ -156,29 +119,6 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`. - Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
- Never commit directly to `master` or `develop`. - Never commit directly to `master` or `develop`.
- Merge strategy: fast-forward or merge — either is fine. - Merge strategy: fast-forward or merge — either is fine.
- Commit messages must be thorough: describe what changed, why, and
list concrete changes as bullet points. Never write single-line
"feat: add X" messages.
**Before every commit (mandatory — agents must not skip):**
```bash
# from repo root; needs Docker running
export POSTGRES_DB=bilhej POSTGRES_USER=bilhej POSTGRES_PASSWORD=test_pw_ci_123
export JWT_SECRET=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
export STRIPE_SECRET_KEY=sk_test_fake STRIPE_WEBHOOK_SECRET=whsec_fake STRIPE_PRICE_ID=price_fake
./gradlew check
```
This runs frontend lint, frontend unit tests, backend tests, coverage
thresholds, Flyway checks, and **all E2E tests in Docker**. **Do not commit or
push if this fails.** Optional local guard: `./scripts/install-pre-commit-hook.sh`
(runs the same `check` on every `git commit`).
**Note for agents:** The pre-commit hook runs the full `./gradlew check` which
takes ~3.5 minutes. If your tool enforces a default timeout (e.g. 120 s on
agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
`./gradlew check` manually before committing.
### Frontend (Vue.js 3) ### Frontend (Vue.js 3)
- `<script setup>` with Composition API only. Never Options API. - `<script setup>` with Composition API only. Never Options API.
@ -186,12 +126,12 @@ agent tool calls), increase it to ≥300 000 ms, or use `--no-verify` and run
- API calls live in `api/` modules, never in components. - API calls live in `api/` modules, never in components.
- Component styles are scoped. - Component styles are scoped.
### Backend (Spring Boot 4) ### Backend (Spring Boot 3)
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`. - Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
- DTOs: prefer Java records. No bare entities in responses. - DTOs: prefer Java records. No bare entities in responses.
- Controllers stay thin. All logic in services. - Controllers stay thin. All logic in services.
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`). - Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs. - No Lombok beyond `@RequiredArgsConstructor`.
### Database ### Database
- Table names: snake_case, plural. PKs: UUID, generated in code. - Table names: snake_case, plural. PKs: UUID, generated in code.
@ -215,15 +155,6 @@ After the address is used to mail the letter, it must be deleted. The Order
entity must NOT have an address field. The address lookup and mailing are entity must NOT have an address field. The address lookup and mailing are
external/human processes in Phase 0. external/human processes in Phase 0.
### E2E must use Docker (not host Playwright)
See **Testing Approach → E2E (Playwright) — Docker only** above. Do not run `npx playwright install` or `npm run test:e2e` on the host when verifying E2E.
### Local email (Mailpit)
`docker compose up` includes Mailpit (`ghcr.io/axllent/mailpit:v1.28`); password-reset mail appears at http://localhost:8025. E2E verifies SMTP via Mailpit API (`frontend/e2e/helpers/mailpit.ts`). Production uses Resend SMTP—see docs/production-email-checklist.md.
### Password reset test token (never in production)
`app.password-reset.expose-token` must stay **false** in prod/default; it is only enabled in `application-docker.yml` for CI E2E so Playwright can read `testToken` from the forgot-password response.
### Stripe webhook signature verification ### Stripe webhook signature verification
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`. Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
Webhook endpoint is public (no auth). Without signature verification an Webhook endpoint is public (no auth). Without signature verification an
@ -243,10 +174,6 @@ public vehicle info) must be excluded from the Spring Security filter chain.
## Testing Approach ## Testing Approach
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
### Backend ### Backend
- JUnit 5 + Mockito for service layer tests. - JUnit 5 + Mockito for service layer tests.
- `@WebMvcTest` for controller tests. - `@WebMvcTest` for controller tests.
@ -257,65 +184,10 @@ the same PR — never merge code without corresponding tests.
### Frontend ### Frontend
- Vitest for composables and utility functions. - Vitest for composables and utility functions.
- Component tests with Vue Test Utils where needed. - Component tests with Vue Test Utils where needed.
- E2E tests with Playwright in `frontend/e2e/`. - E2E tests deferred to Phase 1.
### E2E (Playwright) — **Docker only**
**Agents and humans: never run Playwright on the host.**
| Do **not** run | Why |
|----------------|-----|
| `npx playwright test` | Wrong environment; needs Docker stack |
| `npm run test:e2e` | Same — host Playwright, not supported for agents |
| `npx playwright install` | Do not install browsers on the host; the Playwright image already includes them |
**Always use Docker** (`docker-compose.e2e.yml`): isolated postgres (tmpfs), backend, frontend, Mailpit, and the official Playwright container on the `e2e` network (`PLAYWRIGHT_BASE_URL=http://frontend`).
**Full E2E suite** (same as Forgejo CI / `./gradlew check`):
```bash
# from repo root — set env (or use .env; see .env.example)
cd frontend && npm run test:e2e:ci
# equivalent:
./gradlew frontendE2E
```
**Single spec or project** (stack must be reachable on the `e2e` network):
```bash
# from repo root, after exporting the same vars as frontendE2E / .env
docker compose -f docker-compose.e2e.yml up -d --build postgres mailpit backend frontend
docker compose -f docker-compose.e2e.yml run --rm --build playwright \
sh -c 'npx playwright test admin-fulfillment.spec.ts --project=chromium-serial --reporter=list'
docker compose -f docker-compose.e2e.yml down
```
- Config: `frontend/playwright.config.ts`
- Tests: `frontend/e2e/*.spec.ts`
- Serial specs (shared Mailpit / seeded DB): `admin-fulfillment`, `deferred-payment-admin`, `admin-dashboard`, `account-settings`, `password-reset` — project `chromium-serial` runs **after** parallel `chromium`, `workers: 1`
### CI (future) ### CI (future)
- `./gradlew check` and `npm run test && npm run lint` must pass before merge. - `./mvnw verify` and `npm run test && npm run lint` must pass before merge.
---
## Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage thresholds are enforced during `./gradlew check`. PRs must maintain
or improve coverage.
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
HTML reports:
- Backend: `backend/build/reports/jacoco/index.html`
- Frontend: `frontend/coverage/index.html`
--- ---

View file

@ -15,15 +15,6 @@ Conventions and standards for the BilHej codebase. These exist to keep the proje
- **No commented-out code.** Delete it. Git history keeps it if needed. - **No commented-out code.** Delete it. Git history keeps it if needed.
- **Keep functions small.** A function should do one thing. If it's over 30 lines, it probably does too much. - **Keep functions small.** A function should do one thing. If it's over 30 lines, it probably does too much.
- **No magic numbers.** Use named constants or enums. - **No magic numbers.** Use named constants or enums.
- **Treat warnings as mistakes.** LSP diagnostics, compiler warnings, and lint
warnings are bugs. Never commit code that produces them. If a warning is a
known false positive (e.g. Lombok `@RequiredArgsConstructor` triggering
"uninitialized final field"), suppress it explicitly at the narrowest scope
with a comment explaining why:
- Java: `@SuppressWarnings("...") // Lombok generates constructor`
- TypeScript: `// @ts-expect-error — pinia getActivePinia returns null in test context`
Uncommented suppressions are indistinguishable from ignoring a real problem
and are treated as errors.
--- ---
@ -94,9 +85,9 @@ Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`
|--------------|-----------------------------|----------------------------------| |--------------|-----------------------------|----------------------------------|
| Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` | | Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` |
| Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` | | Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` |
| Composable | camelCase, `use` prefix | `useAuth.ts`, `usePayment.ts` | | Composable | camelCase, `use` prefix | `useAuth.js`, `usePayment.js` |
| Store | camelCase, in `stores/` | `authStore.ts`, `orderStore.ts` | | Store | camelCase, in `stores/` | `authStore.js`, `orderStore.js` |
| API module | camelCase, in `api/` | `orders.ts`, `templates.ts` | | API module | camelCase, in `api/` | `orders.js`, `templates.js` |
### Component Structure ### Component Structure
@ -149,12 +140,12 @@ async function handleSubmit() {
- `v-model` bindings use `defineModel()` or explicit `modelValue` + `update:modelValue`. - `v-model` bindings use `defineModel()` or explicit `modelValue` + `update:modelValue`.
- No global CSS unless it's a design token or reset. Component styles are scoped. - No global CSS unless it's a design token or reset. Component styles are scoped.
- Prefer Pinia stores over prop drilling for shared state (auth, current order). - Prefer Pinia stores over prop drilling for shared state (auth, current order).
- API calls live in `api/*.ts` modules, not in components. - API calls live in `api/*.js` modules, not in components.
- Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor). - Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor).
--- ---
## 4. Backend — Spring Boot 4 ## 4. Backend — Spring Boot 3
### Package Structure ### Package Structure
@ -218,7 +209,7 @@ public class OrderController {
- All responses: `ResponseEntity<T>`. Never return bare entities. - All responses: `ResponseEntity<T>`. Never return bare entities.
- Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`). - Entity fields use `snake_case` column naming explicitly (`@Column(name = "created_at")`).
- Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`. - Database migrations: Flyway. All schema changes go through SQL migration files in `db/migration/`.
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs. - No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
### API Path Conventions ### API Path Conventions
@ -250,7 +241,7 @@ GET /api/vehicles/{plate} Get public vehicle info
### Frontend ### Frontend
```javascript ```javascript
// api/client.ts — centralized fetch wrapper // api/client.js — centralized fetch wrapper
async function request(url, options) { async function request(url, options) {
const response = await fetch(`${BASE_URL}${url}`, { const response = await fetch(`${BASE_URL}${url}`, {
...options, ...options,
@ -298,37 +289,10 @@ public class GlobalExceptionHandler {
## 7. Testing ## 7. Testing
This project follows **Test-Driven Development (TDD)**. Write tests before
or alongside implementation. Every feature ticket should include tests in
the same PR — never merge code without corresponding tests.
- Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`. - Backend: JUnit 5 + Mockito. Service layer tests as unit tests. Controller tests with `@WebMvcTest`.
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils. - Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`. - Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
- Aim for test coverage on business logic, not on getters/setters/boilerplate. - Aim for test coverage on business logic, not on getters/setters/boilerplate.
- All database interaction in tests must go through JPA repositories
or EntityManager. Never use JdbcTemplate, DataSource queries, or
raw SQL in test code. Tests interact with the database the same way
production code does: through the ORM.
### Coverage
```bash
./gradlew coverage # backend + frontend tests with coverage
```
Coverage is enforced via `./gradlew check`. Thresholds:
| Layer | Lines | Branches | Functions |
|----------|-------|----------|-----------|
| Backend | 70% | 60% | — |
| Frontend | 70% | 60% | 70% |
- Backend: JaCoCo (`backend/build/reports/jacoco/index.html`).
- Frontend: Vitest v8 provider (`frontend/coverage/index.html`).
- PRs must maintain or improve coverage levels. If a new feature changes
coverage, update the test suite — never lower thresholds without discussion.
--- ---

404
README.md
View file

@ -11,7 +11,7 @@ The user enters a registration number, composes a letter (from a template or fre
| Layer | Technology | | Layer | Technology |
|-------------|-----------------------------------------| |-------------|-----------------------------------------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia | | Frontend | Vue.js 3 (Composition API), Vite, Pinia |
| Backend | Java 21, Spring Boot 4, Gradle | | Backend | Java 21, Spring Boot 3 |
| Database | PostgreSQL 16 | | Database | PostgreSQL 16 |
| Auth | Spring Security + JWT | | Auth | Spring Security + JWT |
| Payments | Stripe (cards + Swish) | | Payments | Stripe (cards + Swish) |
@ -34,53 +34,13 @@ The user enters a registration number, composes a letter (from a template or fre
git clone <repo-url> bilhej git clone <repo-url> bilhej
cd bilhej cd bilhej
cp .env.example .env # fill in your keys cp .env.example .env # fill in your keys
docker compose up -d # or: ./gradlew up docker compose up -d
``` ```
The app will be available at: The app will be available at:
- Frontend: `http://localhost:3000` - Frontend: `http://localhost:3000`
- Backend API: `http://localhost:8080` - Backend API: `http://localhost:8080`
- PostgreSQL: `localhost:5432` - 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`) |
--- ---
@ -97,147 +57,6 @@ Copy `.env.example` to `.env` and fill in:
| `STRIPE_SECRET_KEY` | Stripe secret key | | `STRIPE_SECRET_KEY` | Stripe secret key |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
| `STRIPE_PRICE_ID` | Stripe price ID for single letter | | `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:** [Resend](https://resend.com) via SMTP (no Resend Java SDK required). See
[docs/production-email-checklist.md](docs/production-email-checklist.md).
1. Verify domain **bilhej.se** in Resend (SPF + DKIM DNS records)
2. Create an API key (`re_...`)
3. On the production server `.env`:
```bash
APP_PUBLIC_BASE_URL=https://bilhej.se
MAIL_HOST=smtp.resend.com
MAIL_PORT=587
MAIL_USERNAME=resend
MAIL_PASSWORD=re_xxxxxxxx
MAIL_FROM=noreply@bilhej.se
```
`MAIL_USERNAME` is always the literal string `resend`; `MAIL_PASSWORD` is the API key.
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'
```
--- ---
@ -256,11 +75,11 @@ bilhej/
│ │ ├── api/ # API client and endpoints │ │ ├── api/ # API client and endpoints
│ │ ├── assets/ # Static assets, CSS │ │ ├── assets/ # Static assets, CSS
│ │ ├── App.vue │ │ ├── App.vue
│ │ └── main.ts │ │ └── main.js
│ ├── index.html │ ├── index.html
│ ├── vite.config.ts │ ├── vite.config.js
│ └── package.json │ └── package.json
├── backend/ # Spring Boot 4 ├── backend/ # Spring Boot 3
│ ├── src/main/java/se/bilhalsning/ │ ├── src/main/java/se/bilhalsning/
│ │ ├── BilHejApplication.java │ │ ├── BilHejApplication.java
│ │ ├── config/ # Security, CORS, Stripe config │ │ ├── config/ # Security, CORS, Stripe config
@ -271,222 +90,36 @@ bilhej/
│ │ ├── service/ # Business logic │ │ ├── service/ # Business logic
│ │ └── security/ # JWT filter, user details │ │ └── security/ # JWT filter, user details
│ └── src/main/resources/ │ └── src/main/resources/
│ ├── application.yml # default profile (H2) │ ├── application.yml
│ ├── application-docker.yml # docker profile (PostgreSQL)
│ └── db/migration/ # Flyway migrations │ └── db/migration/ # Flyway migrations
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR) ├── docker-compose.yml
├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped
├── docker/ ├── docker/
│ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun │ ├── backend.Dockerfile
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root) │ └── frontend.Dockerfile
│ ├── 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 ├── .env.example
├── README.md ├── README.md
├── REQUIREMENTS.md ├── REQUIREMENTS.md
└── CODING_GUIDELINES.md ├── CODING_GUIDELINES.md
``` └── ARCHITECTURE.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) |
| `APP_PUBLIC_BASE_URL` | `https://bilhej.se` (password-reset links) |
| `MAIL_HOST` | `smtp.resend.com` |
| `MAIL_PORT` | `587` |
| `MAIL_USERNAME` | `resend` (literal string) |
| `MAIL_PASSWORD` | Resend API key (`re_...`; rotate if ever exposed) |
| `MAIL_FROM` | `noreply@bilhej.se` (must be on verified domain) |
| `VITE_UMAMI_WEBSITE_ID` | Umami website UUID for `bilhej.se` (see `docs/umami-analytics.md`) |
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. Fill in both fields (Forgejo requires `type` on inputs — see `deploy.yml`):
- **Use workflow from:** `master` (which commit to build). Do not confuse this with the deploy tag below.
- **Version tag:** label created by the pipeline (e.g. `v0.1.2`). Change this each release; default `v0.1.0` is only a placeholder.
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 ## 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) ### Frontend (dev server with HMR)
```bash ```bash
cd frontend cd frontend
npm install # first time only npm install
npm run dev # :3000 with HMR npm run dev
``` ```
### Backend (IDE or CLI) ### Backend (IDE or CLI)
```bash ```bash
./gradlew :backend:bootRun # :8080, profile: default (H2) cd backend
./mvnw spring-boot:run
``` ```
### Stripe Webhooks (local testing) ### Stripe Webhooks (local testing)
@ -495,15 +128,10 @@ npm run dev # :3000 with HMR
stripe listen --forward-to localhost:8080/api/webhooks/stripe stripe listen --forward-to localhost:8080/api/webhooks/stripe
``` ```
### Database reset
```bash
./gradlew reset # wipes DB volume and restarts containers
```
--- ---
## Related Documents ## Related Documents
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model - [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards - [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Detailed architecture and data flow

View file

@ -178,7 +178,7 @@ the user assumes full responsibility for content.
| Layer | Technology | | Layer | Technology |
|-------|-----------| |-------|-----------|
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router | | Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA | | Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
| Database | PostgreSQL 16 | | Database | PostgreSQL 16 |
| Deployment | Docker, Docker Compose | | Deployment | Docker, Docker Compose |
| Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL | | Hosting (Phase 0) | Home server via dynamic DNS or static IP, Let's Encrypt SSL |
@ -209,7 +209,7 @@ the user assumes full responsibility for content.
└──────────────────┬───────────────────────┘ └──────────────────┬───────────────────────┘
│ REST API calls │ REST API calls
┌──────────────────▼───────────────────────┐ ┌──────────────────▼───────────────────────┐
│ Spring Boot 4 (Java 21) │ │ Spring Boot 3 (Java 21) │
│ Port: 8080 │ │ Port: 8080 │
│ ┌────────────┐ ┌────────────────────┐ │ │ ┌────────────┐ ┌────────────────────┐ │
│ │ Spring │ │ Service Layer │ │ │ │ Spring │ │ Service Layer │ │
@ -446,7 +446,7 @@ Gross margin: 14 SEK
| Is a license plate personal data? | Yes (it directly identifies a vehicle owner). | | Is a license plate personal data? | Yes (it directly identifies a vehicle owner). |
| Is an address personal data? | Yes. | | Is an address personal data? | Yes. |
| What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. | | What if we only process address transiently? | Data minimization is a GDPR principle (Art. 5(1)(c)). Transient processing with immediate deletion is a strong compliance posture. |
| Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: kontakt@bilhej.se"_ | | Do we need to inform the recipient? | Yes, GDPR Art. 14 requires informing the data subject. The letter itself can serve this purpose — include a footer like: _"Detta brev skickades via BilHej.se. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se"_ |
### 11.2 Transportstyrelsen Access ### 11.2 Transportstyrelsen Access
@ -556,7 +556,7 @@ For Phase 0 with manual processing, staying unregistered is workable. If revenue
``` ```
Frontend: Vue.js 3, Vite, Pinia, Vue Router Frontend: Vue.js 3, Vite, Pinia, Vue Router
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
Database: PostgreSQL 16 Database: PostgreSQL 16
Deploy: Docker, Docker Compose, Nginx reverse proxy Deploy: Docker, Docker Compose, Nginx reverse proxy
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1) Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)

View file

@ -1,3 +0,0 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

37
backend/.gitignore vendored
View file

@ -1,37 +0,0 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

View file

@ -1,100 +0,0 @@
plugins {
id 'java'
id 'jacoco'
id 'org.springframework.boot' version '4.0.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'se.bilhalsning'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.jsoup:jsoup:1.18.1'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
useJUnitPlatform()
finalizedBy jacocoTestReport
}
jacoco {
toolVersion = "0.8.12"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
csv.required = false
html.required = true
}
}
jacocoTestCoverageVerification {
dependsOn jacocoTestReport
violationRules {
rule {
limit {
minimum = 0.70
}
}
rule {
limit {
counter = 'BRANCH'
minimum = 0.60
}
}
}
}
tasks.register('flywayMigrationCheck', Exec) {
group = 'verification'
description = 'Ensure Flyway migrations are unique, immutable, and use new version numbers'
workingDir = rootProject.projectDir
commandLine 'bash', 'scripts/check-flyway-migrations.sh'
}
tasks.named('check').configure {
dependsOn jacocoTestCoverageVerification, flywayMigrationCheck
}
tasks.register('hashPassword', JavaExec) {
group = 'utility'
description = 'Print BCrypt hash for a password (strength 12). Usage: -Ppassword=secret'
classpath = sourceSets.test.runtimeClasspath
mainClass = 'se.bilhalsning.tools.BcryptHashCli'
args = project.findProperty('password') ? [project.property('password')] : []
}

93
backend/gradlew.bat vendored
View file

@ -1,93 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,13 +0,0 @@
package se.bilhalsning;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BilHejApplication {
public static void main(String[] args) {
SpringApplication.run(BilHejApplication.class, args);
}
}

View file

@ -1,50 +0,0 @@
package se.bilhalsning.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@Component
@Profile("prod")
@RequiredArgsConstructor
@Slf4j
public class AdminBootstrap implements ApplicationRunner {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Value("${app.admin.email:}")
private String adminEmail;
@Value("${app.admin.password:}")
private String adminPassword;
@Override
public void run(ApplicationArguments args) {
if (userRepository.existsByRole("admin")) {
log.info("Admin account already present, skipping bootstrap");
return;
}
if (!StringUtils.hasText(adminEmail) || !StringUtils.hasText(adminPassword)) {
throw new IllegalStateException(
"Production requires ADMIN_EMAIL and ADMIN_PASSWORD when no admin user exists");
}
User admin = new User();
admin.setEmail(adminEmail.trim());
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
admin.setRole("admin");
userRepository.save(admin);
log.info("Created production admin account for {}", admin.getEmail());
}
}

View file

@ -1,24 +0,0 @@
package se.bilhalsning.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile("prod")
public class ProdFlywayConfig {
/**
* Prod databases may record dev seed migrations (V2, V4, V6) from before they moved to
* db/dev-migration/. Repair marks those missing scripts as deleted so validate passes.
*/
@Bean
public FlywayMigrationStrategy prodFlywayMigrationStrategy() {
return (Flyway flyway) -> {
flyway.repair();
flyway.migrate();
};
}
}

View file

@ -1,78 +0,0 @@
package se.bilhalsning.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import se.bilhalsning.dto.ErrorResponse;
import se.bilhalsning.security.JwtAuthenticationFilter;
import se.bilhalsning.security.JwtService;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
static final String UNAUTHENTICATED_MESSAGE =
"Din session har löpt ut eller är ogiltig. Logga in igen.";
static final String FORBIDDEN_MESSAGE =
"Du har inte behörighet att utföra denna åtgärd.";
private final ObjectMapper objectMapper = new ObjectMapper();
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtService jwtService(@Value("${app.jwt.secret}") String secret) {
return new JwtService(secret);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/register",
"/api/auth/login",
"/api/auth/forgot-password",
"/api/auth/reset-password",
"/api/auth/confirm-email-change")
.permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(eh -> eh
.authenticationEntryPoint((request, response, ex) ->
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
.accessDeniedHandler((request, response, ex) ->
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private void writeError(HttpServletResponse response, HttpStatus status, String message)
throws java.io.IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new ErrorResponse(message)));
}
}

View file

@ -1,66 +0,0 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AdminOrderMapper;
import se.bilhalsning.dto.AdminOrderResponse;
import se.bilhalsning.dto.RegisterShipmentRequest;
import se.bilhalsning.dto.UpdateAdminNotesRequest;
import se.bilhalsning.dto.UpdateStatusRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final OrderService orderService;
private final AdminOrderWorkflowService adminOrderWorkflowService;
@GetMapping("/orders")
public ResponseEntity<List<AdminOrderResponse>> listAllOrders() {
List<AdminOrderResponse> orders = orderService.getAllOrders().stream()
.map(AdminOrderMapper::toResponse)
.toList();
return ResponseEntity.ok(orders);
}
@PatchMapping("/orders/{id}/status")
public ResponseEntity<AdminOrderResponse> updateStatus(
@PathVariable UUID id,
@Valid @RequestBody UpdateStatusRequest request) {
Order order = adminOrderWorkflowService.updateOrderStatus(id, request.status());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
@PatchMapping("/orders/{id}/register-shipment")
public ResponseEntity<AdminOrderResponse> registerShipment(
@PathVariable UUID id,
@Valid @RequestBody RegisterShipmentRequest request) {
Order order = adminOrderWorkflowService.registerShipment(
id,
request.trackingInput(),
request.notifyCustomerOrDefault());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
@PatchMapping("/orders/{id}/notes")
public ResponseEntity<AdminOrderResponse> updateNotes(
@PathVariable UUID id,
@RequestBody UpdateAdminNotesRequest request) {
Order order = adminOrderWorkflowService.updateAdminNotes(id, request.adminNotes());
return ResponseEntity.ok(AdminOrderMapper.toResponse(order));
}
}

View file

@ -1,100 +0,0 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.AuthResponse;
import se.bilhalsning.dto.ChangeEmailRequest;
import se.bilhalsning.dto.ChangeEmailResponse;
import se.bilhalsning.dto.ChangePasswordRequest;
import se.bilhalsning.dto.ConfirmEmailChangeRequest;
import se.bilhalsning.dto.ForgotPasswordRequest;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.ForgotPasswordResponse;
import se.bilhalsning.dto.MessageResponse;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.dto.ResetPasswordRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final PasswordResetService passwordResetService;
private final EmailChangeService emailChangeService;
private final JwtService jwtService;
private static final String FORGOT_PASSWORD_MESSAGE =
"Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet.";
private static final String CHANGE_EMAIL_MESSAGE =
"Vi har skickat en bekräftelselänk till din nya e-postadress.";
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
userService.createUser(request.email(), request.password());
String token = jwtService.generateToken(request.email().toLowerCase().trim(), "user");
return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
User user = userService.authenticate(request.email(), request.password());
String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token));
}
@PostMapping("/forgot-password")
public ResponseEntity<ForgotPasswordResponse> forgotPassword(
@Valid @RequestBody ForgotPasswordRequest request) {
return ResponseEntity.ok(ForgotPasswordResponse.of(
FORGOT_PASSWORD_MESSAGE, passwordResetService.requestReset(request.email())));
}
@PostMapping("/reset-password")
public ResponseEntity<MessageResponse> resetPassword(
@Valid @RequestBody ResetPasswordRequest request) {
passwordResetService.resetPassword(request.token(), request.password());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@PostMapping("/change-password")
public ResponseEntity<MessageResponse> changePassword(
@Valid @RequestBody ChangePasswordRequest request,
@AuthenticationPrincipal UserDetails principal) {
userService.changePassword(
principal.getUsername(), request.currentPassword(), request.newPassword());
return ResponseEntity.ok(new MessageResponse("Lösenordet har uppdaterats."));
}
@PostMapping("/change-email")
public ResponseEntity<ChangeEmailResponse> changeEmail(
@Valid @RequestBody ChangeEmailRequest request,
@AuthenticationPrincipal UserDetails principal) {
Optional<String> testToken = emailChangeService.requestChange(
principal.getUsername(), request.password(), request.newEmail());
return ResponseEntity.ok(ChangeEmailResponse.of(CHANGE_EMAIL_MESSAGE, testToken));
}
@PostMapping("/confirm-email-change")
public ResponseEntity<AuthResponse> confirmEmailChange(
@Valid @RequestBody ConfirmEmailChangeRequest request) {
User user = emailChangeService.confirmChange(request.token(), request.password());
String token = jwtService.generateToken(user.getEmail(), user.getRole());
return ResponseEntity.ok(new AuthResponse(token));
}
}

View file

@ -1,115 +0,0 @@
package se.bilhalsning.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.CreateOrderRequest;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.dto.UpdateOrderRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final UserService userService;
@GetMapping
public ResponseEntity<List<OrderResponse>> list(@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
List<OrderResponse> orders = orderService.getOrdersByUserId(user.getId()).stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(orders);
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.getOrderById(id);
if (!order.getUserId().equals(user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(toResponse(order));
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.createOrder(
user.getId(),
request.plate(),
request.letterText()
);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
}
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> update(
@PathVariable UUID id,
@Valid @RequestBody UpdateOrderRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
return ResponseEntity.ok(toResponse(order));
}
@PostMapping("/{id}/cancel")
public ResponseEntity<OrderResponse> cancel(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.cancelOrder(id, user.getId());
return ResponseEntity.ok(toResponse(order));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -1,68 +0,0 @@
package se.bilhalsning.controller;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
private final OrderService orderService;
private final UserService userService;
private final String swishNumber;
private final int letterPrice;
public PaymentController(
OrderService orderService,
UserService userService,
@Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService;
this.userService = userService;
this.swishNumber = swishNumber;
this.letterPrice = letterPrice;
}
@PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.confirmPayment(orderId, user.getId());
return ResponseEntity.ok(toResponse(order));
}
@GetMapping("/swish-info")
public ResponseEntity<Map<String, Object>> swishInfo() {
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getCreatedAt()
);
}
}

View file

@ -1,24 +0,0 @@
package se.bilhalsning.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.service.VehicleLookupService;
@RestController
@RequestMapping("/api/vehicles")
@RequiredArgsConstructor
public class VehicleController {
private final VehicleLookupService vehicleLookupService;
@GetMapping("/{plate}")
public ResponseEntity<VehicleInfoResponse> lookup(@PathVariable String plate) {
VehicleInfoResponse info = vehicleLookupService.lookup(plate);
return ResponseEntity.ok(info);
}
}

View file

@ -1,26 +0,0 @@
package se.bilhalsning.dto;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.AdminOrderStatusRules;
public final class AdminOrderMapper {
private AdminOrderMapper() {}
public static AdminOrderResponse toResponse(Order order) {
String email = order.getUser() != null ? order.getUser().getEmail() : "";
return new AdminOrderResponse(
order.getId(),
email,
order.getPlate(),
order.getLetterText(),
order.getStatus().getValue(),
order.getTrackingId(),
order.getAmountPaid(),
order.getShippedAt(),
order.getAdminNotes(),
order.getCreatedAt(),
AdminOrderStatusRules.allowedStatusValues(order),
AdminOrderStatusRules.canRegisterShipment(order));
}
}

View file

@ -1,21 +0,0 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record AdminOrderResponse(
UUID id,
String email,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant shippedAt,
String adminNotes,
Instant createdAt,
List<String> allowedStatuses,
boolean canRegisterShipment
) {}

View file

@ -1,3 +0,0 @@
package se.bilhalsning.dto;
public record AuthResponse(String token) {}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ChangeEmailRequest(
@NotBlank @Email String newEmail, @NotBlank String password) {}

View file

@ -1,12 +0,0 @@
package se.bilhalsning.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Optional;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ChangeEmailResponse(String message, String testToken) {
public static ChangeEmailResponse of(String message, Optional<String> testToken) {
return new ChangeEmailResponse(message, testToken.orElse(null));
}
}

View file

@ -1,8 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ChangePasswordRequest(
@NotBlank String currentPassword,
@NotBlank @Size(min = 8) String newPassword) {}

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}

View file

@ -1,15 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "Registreringsnummer krävs")
@Pattern(regexp = "^[A-Za-z]{3}\\d{2}[A-Za-z0-9]$", message = "Ogiltigt registreringsnummer")
String plate,
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -1,3 +0,0 @@
package se.bilhalsning.dto;
public record ErrorResponse(String message) {}

View file

@ -1,6 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record ForgotPasswordRequest(@NotBlank @Email String email) {}

View file

@ -1,12 +0,0 @@
package se.bilhalsning.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.Optional;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ForgotPasswordResponse(String message, String testToken) {
public static ForgotPasswordResponse of(String message, Optional<String> testToken) {
return new ForgotPasswordResponse(message, testToken.orElse(null));
}
}

View file

@ -1,9 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {}

View file

@ -1,3 +0,0 @@
package se.bilhalsning.dto;
public record MessageResponse(String message) {}

View file

@ -1,15 +0,0 @@
package se.bilhalsning.dto;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderResponse(
UUID id,
String plate,
String letterText,
String status,
String trackingId,
BigDecimal amountPaid,
Instant createdAt
) {}

View file

@ -1,10 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8) String password
) {}

View file

@ -1,13 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
public record RegisterShipmentRequest(
@NotBlank(message = "Spårnings-ID krävs")
String trackingInput,
Boolean notifyCustomer
) {
public boolean notifyCustomerOrDefault() {
return notifyCustomer == null || notifyCustomer;
}
}

View file

@ -1,9 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ResetPasswordRequest(
@NotBlank String token,
@NotBlank @Size(min = 8) String password
) {}

View file

@ -1,5 +0,0 @@
package se.bilhalsning.dto;
public record UpdateAdminNotesRequest(
String adminNotes
) {}

View file

@ -1,10 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateOrderRequest(
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -1,13 +0,0 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|processing|sent|delivered|failed|cancelled",
message = "Ogiltig status"
)
String status
) {}

View file

@ -1,9 +0,0 @@
package se.bilhalsning.dto;
public record VehicleInfoResponse(
String make,
String model,
int year,
String color,
String fuel
) {}

View file

@ -1,106 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "email_change_tokens")
public class EmailChangeToken {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "new_email", nullable = false)
private String newEmail;
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
if (this.createdAt == null) {
this.createdAt = Instant.now();
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getNewEmail() {
return newEmail;
}
public void setNewEmail(String newEmail) {
this.newEmail = newEmail;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View file

@ -1,162 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "orders")
public class Order {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
private User user;
@Column(name = "plate", nullable = false, length = 10)
private String plate;
@Column(name = "letter_text", nullable = false, columnDefinition = "text")
private String letterText;
@Column(name = "status", nullable = false, length = 30)
private OrderStatus status = OrderStatus.PENDING_PAYMENT;
@Column(name = "amount_paid", precision = 10, scale = 2)
private BigDecimal amountPaid;
@Column(name = "tracking_id", length = 100)
private String trackingId;
@Column(name = "shipped_at")
private Instant shippedAt;
@Column(name = "admin_notes", columnDefinition = "text")
private String adminNotes;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
Instant now = Instant.now();
if (this.createdAt == null) {
this.createdAt = now;
}
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getPlate() {
return plate;
}
public void setPlate(String plate) {
this.plate = plate;
}
public String getLetterText() {
return letterText;
}
public void setLetterText(String letterText) {
this.letterText = letterText;
}
public OrderStatus getStatus() {
return status;
}
public void setStatus(OrderStatus status) {
this.status = status;
}
public BigDecimal getAmountPaid() {
return amountPaid;
}
public void setAmountPaid(BigDecimal amountPaid) {
this.amountPaid = amountPaid;
}
public String getTrackingId() {
return trackingId;
}
public void setTrackingId(String trackingId) {
this.trackingId = trackingId;
}
public Instant getShippedAt() {
return shippedAt;
}
public void setShippedAt(Instant shippedAt) {
this.shippedAt = shippedAt;
}
public String getAdminNotes() {
return adminNotes;
}
public void setAdminNotes(String adminNotes) {
this.adminNotes = adminNotes;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}

View file

@ -1,21 +0,0 @@
package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
PROCESSING("processing"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed"),
CANCELLED("cancelled");
private final String value;
OrderStatus(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -1,26 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class OrderStatusConverter implements AttributeConverter<OrderStatus, String> {
@Override
public String convertToDatabaseColumn(OrderStatus status) {
return status != null ? status.getValue() : null;
}
@Override
public OrderStatus convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (OrderStatus s : OrderStatus.values()) {
if (s.getValue().equals(dbData)) {
return s;
}
}
throw new IllegalArgumentException("Unknown order status value: " + dbData);
}
}

View file

@ -1,95 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "password_reset_tokens")
public class PasswordResetToken {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token_hash", nullable = false, length = 64)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "used_at")
private Instant usedAt;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
if (this.createdAt == null) {
this.createdAt = Instant.now();
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public Instant getUsedAt() {
return usedAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}

View file

@ -1,18 +0,0 @@
package se.bilhalsning.entity;
public enum Subscription {
NONE("none"),
BASIC("basic"),
PRO("pro");
private final String value;
Subscription(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}

View file

@ -1,26 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
@Converter(autoApply = true)
public class SubscriptionConverter implements AttributeConverter<Subscription, String> {
@Override
public String convertToDatabaseColumn(Subscription subscription) {
return subscription != null ? subscription.getValue() : null;
}
@Override
public Subscription convertToEntityAttribute(String dbData) {
if (dbData == null) {
return null;
}
for (Subscription subscription : Subscription.values()) {
if (subscription.getValue().equals(dbData)) {
return subscription;
}
}
throw new IllegalArgumentException("Unknown subscription value: " + dbData);
}
}

View file

@ -1,112 +0,0 @@
package se.bilhalsning.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Convert;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users")
public class User {
@Id
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
private UUID id;
@Column(name = "email", nullable = false, unique = true, length = 255)
private String email;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Convert(converter = SubscriptionConverter.class)
@Column(name = "subscription", nullable = false, length = 20)
private Subscription subscription = Subscription.NONE;
@Column(name = "role", nullable = false, length = 20)
private String role = "user";
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onCreate() {
if (this.id == null) {
this.id = UUID.randomUUID();
}
Instant now = Instant.now();
if (this.createdAt == null) {
this.createdAt = now;
}
this.updatedAt = now;
}
@PreUpdate
void onUpdate() {
this.updatedAt = Instant.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email != null ? email.toLowerCase().trim() : null;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public Subscription getSubscription() {
return subscription;
}
public void setSubscription(Subscription subscription) {
this.subscription = subscription;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}

View file

@ -1,8 +0,0 @@
package se.bilhalsning.exception;
public class EmailAlreadyExistsException extends RuntimeException {
public EmailAlreadyExistsException(String email) {
super("Email already registered: " + email);
}
}

View file

@ -1,8 +0,0 @@
package se.bilhalsning.exception;
public class EmailChangeTokenInvalidException extends RuntimeException {
public EmailChangeTokenInvalidException() {
super("Bekräftelselänken är ogiltig eller har gått ut.");
}
}

View file

@ -1,101 +0,0 @@
package se.bilhalsning.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import se.bilhalsning.dto.ErrorResponse;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(PasswordResetTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handlePasswordResetTokenInvalid(
PasswordResetTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailChangeTokenInvalidException.class)
public ResponseEntity<ErrorResponse> handleEmailChangeTokenInvalid(
EmailChangeTokenInvalidException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("E-postadressen är redan registrerad"));
}
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(VehicleNotFoundException.class)
public ResponseEntity<ErrorResponse> handleVehicleNotFound(VehicleNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("Inget fordon hittades"));
}
@ExceptionHandler(VehicleLookupException.class)
public ResponseEntity<ErrorResponse> handleVehicleLookup(VehicleLookupException ex) {
log.error("Vehicle lookup failed", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.reduce((a, b) -> a + ", " + b)
.orElse("Ogiltig indata");
return ResponseEntity
.badRequest()
.body(new ErrorResponse(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("Ett internt fel uppstod"));
}
}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() {
super("Felaktig e-post eller lösenord");
}
}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.exception;
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) {
super(message);
}
}

View file

@ -1,9 +0,0 @@
package se.bilhalsning.exception;
import java.util.UUID;
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(UUID id) {
super("Order not found: " + id);
}
}

View file

@ -1,8 +0,0 @@
package se.bilhalsning.exception;
public class PasswordResetTokenInvalidException extends RuntimeException {
public PasswordResetTokenInvalidException() {
super("Återställningslänken är ogiltig eller har gått ut");
}
}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.exception;
public class VehicleLookupException extends RuntimeException {
public VehicleLookupException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -1,7 +0,0 @@
package se.bilhalsning.exception;
public class VehicleNotFoundException extends RuntimeException {
public VehicleNotFoundException(String plate) {
super("Vehicle not found: " + plate);
}
}

View file

@ -1,18 +0,0 @@
package se.bilhalsning.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import se.bilhalsning.entity.EmailChangeToken;
public interface EmailChangeTokenRepository extends JpaRepository<EmailChangeToken, UUID> {
Optional<EmailChangeToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
@Modifying
@Query("DELETE FROM EmailChangeToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
void deleteUnusedByUserId(@Param("userId") UUID userId);
}

View file

@ -1,24 +0,0 @@
package se.bilhalsning.repository;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByUserIdOrderByCreatedAtDesc(UUID userId);
List<Order> findByStatus(OrderStatus status);
@EntityGraph(attributePaths = {"user"})
List<Order> findAllByOrderByCreatedAtDesc();
@EntityGraph(attributePaths = {"user"})
Optional<Order> findWithUserById(UUID id);
}

View file

@ -1,18 +0,0 @@
package se.bilhalsning.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import se.bilhalsning.entity.PasswordResetToken;
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
Optional<PasswordResetToken> findByTokenHashAndUsedAtIsNull(String tokenHash);
@Modifying
@Query("DELETE FROM PasswordResetToken t WHERE t.user.id = :userId AND t.usedAt IS NULL")
void deleteUnusedByUserId(@Param("userId") UUID userId);
}

View file

@ -1,17 +0,0 @@
package se.bilhalsning.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import se.bilhalsning.entity.User;
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
boolean existsByRole(String role);
}

View file

@ -1,65 +0,0 @@
package se.bilhalsning.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import io.jsonwebtoken.JwtException;
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
String username;
try {
username = jwtService.extractUsername(token);
} catch (JwtException e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(token)) {
String role = jwtService.extractRole(token);
List<org.springframework.security.core.authority.SimpleGrantedAuthority> authorities =
new java.util.ArrayList<>();
if (role != null) {
authorities.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
"ROLE_" + role.toUpperCase()));
}
var authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}

View file

@ -1,71 +0,0 @@
package se.bilhalsning.security;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class JwtService {
private static final long DEFAULT_EXPIRATION_MS = 86_400_000;
private final SecretKey secretKey;
private final long expirationMs;
public JwtService(String secret) {
this(secret, DEFAULT_EXPIRATION_MS);
}
public JwtService(String secret, long expirationMs) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.expirationMs = expirationMs;
}
public String generateToken(String email) {
return generateToken(email, "user");
}
public String generateToken(String email, String role) {
return Jwts.builder()
.subject(email)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey)
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public String extractRole(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
public boolean isTokenValid(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (ExpiredJwtException e) {
return false;
} catch (Exception e) {
return false;
}
}
}

View file

@ -1,25 +0,0 @@
package se.bilhalsning.security;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import se.bilhalsning.repository.UserRepository;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
String normalizedEmail = email.toLowerCase().trim();
return userRepository.findByEmail(normalizedEmail)
.map(user -> new User(user.getEmail(), user.getPasswordHash(), List.of()))
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + normalizedEmail));
}
}

View file

@ -1,81 +0,0 @@
package se.bilhalsning.service;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
/**
* Admin status transitions and UI affordances. Single source of truth for
* {@link AdminOrderResponse#allowedStatuses()} and {@link AdminOrderResponse#canRegisterShipment()}.
*/
public final class AdminOrderStatusRules {
private AdminOrderStatusRules() {}
public static List<String> allowedStatusValues(Order order) {
OrderStatus current = order.getStatus();
LinkedHashSet<OrderStatus> options = new LinkedHashSet<>();
options.add(current);
for (OrderStatus target : allowedTargets(current, order)) {
options.add(target);
}
List<String> values = new ArrayList<>();
for (OrderStatus status : options) {
values.add(status.getValue());
}
return values;
}
public static boolean canRegisterShipment(Order order) {
OrderStatus status = order.getStatus();
if (status == OrderStatus.PROCESSING
|| status == OrderStatus.SENT
|| status == OrderStatus.DELIVERED) {
return true;
}
return status == OrderStatus.FAILED && order.getAmountPaid() != null;
}
public static void validateTransition(Order order, OrderStatus to) {
OrderStatus from = order.getStatus();
if (from == to) {
return;
}
if (!allowedTargets(from, order).contains(to)) {
throw new InvalidOrderStateException(
"Status kan inte ändras från " + from.getValue() + " till " + to.getValue());
}
}
private static List<OrderStatus> allowedTargets(OrderStatus from, Order order) {
return switch (from) {
case PENDING_PAYMENT -> List.of(OrderStatus.FAILED);
case PROCESSING -> List.of(OrderStatus.FAILED);
case SENT -> List.of(OrderStatus.DELIVERED, OrderStatus.FAILED);
case DELIVERED -> List.of(OrderStatus.FAILED);
case FAILED -> allowedTargetsFromFailed(order);
default -> List.of();
};
}
private static List<OrderStatus> allowedTargetsFromFailed(Order order) {
if (hasTrackingId(order)) {
return List.of(
OrderStatus.PROCESSING,
OrderStatus.SENT,
OrderStatus.DELIVERED);
}
if (order.getAmountPaid() == null) {
return List.of(OrderStatus.PENDING_PAYMENT);
}
return List.of(OrderStatus.PROCESSING, OrderStatus.SENT);
}
private static boolean hasTrackingId(Order order) {
String trackingId = order.getTrackingId();
return trackingId != null && !trackingId.isBlank();
}
}

View file

@ -1,88 +0,0 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import se.bilhalsning.util.PostNordTrackingNormalizer;
import java.time.Instant;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AdminOrderWorkflowService {
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order updateOrderStatus(UUID orderId, String statusString) {
Order order = requireOrder(orderId);
OrderStatus newStatus = parseStatus(statusString);
OrderStatus previousStatus = order.getStatus();
AdminOrderStatusRules.validateTransition(order, newStatus);
order.setStatus(newStatus);
if (newStatus == OrderStatus.SENT
&& previousStatus == OrderStatus.FAILED
&& order.getShippedAt() == null) {
order.setShippedAt(Instant.now());
}
Order saved = orderRepository.save(order);
if (newStatus == OrderStatus.FAILED && previousStatus != OrderStatus.FAILED) {
orderNotificationService.notifyOrderFailed(saved);
}
return saved;
}
public Order registerShipment(UUID orderId, String rawTrackingInput, boolean notifyCustomer) {
String trackingId = PostNordTrackingNormalizer.normalize(rawTrackingInput);
Order order = requireOrder(orderId);
OrderStatus previousStatus = order.getStatus();
if (!AdminOrderStatusRules.canRegisterShipment(order)) {
throw new InvalidOrderStateException(
"Beställningen kan inte registreras som utskickad i detta tillstånd");
}
if (previousStatus == OrderStatus.FAILED && order.getAmountPaid() == null) {
throw new InvalidOrderStateException(
"Obetalda misslyckade beställningar kan inte registreras som utskickade");
}
boolean firstShipment = previousStatus == OrderStatus.PROCESSING
|| previousStatus == OrderStatus.FAILED;
order.setTrackingId(trackingId);
if (firstShipment) {
order.setStatus(OrderStatus.SENT);
order.setShippedAt(Instant.now());
}
Order saved = orderRepository.save(order);
if (notifyCustomer && firstShipment) {
orderNotificationService.notifyOrderSent(saved, trackingId);
}
return saved;
}
public Order updateAdminNotes(UUID orderId, String adminNotes) {
Order order = requireOrder(orderId);
order.setAdminNotes(adminNotes);
return orderRepository.save(order);
}
private Order requireOrder(UUID orderId) {
return orderRepository.findWithUserById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
private static OrderStatus parseStatus(String statusString) {
try {
return OrderStatus.valueOf(statusString.toUpperCase());
} catch (IllegalArgumentException ex) {
throw new IllegalArgumentException("Ogiltig status");
}
}
}

View file

@ -1,70 +0,0 @@
package se.bilhalsning.service;
import java.time.Instant;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.EmailChangeToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
import se.bilhalsning.repository.EmailChangeTokenRepository;
@Service
@RequiredArgsConstructor
public class EmailChangeService {
private static final long TOKEN_TTL_HOURS = 24;
private final UserService userService;
private final EmailChangeTokenRepository tokenRepository;
private final EmailService emailService;
private final PasswordResetService passwordResetService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.email-change.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
User user = userService.authenticate(currentEmail, password);
userService.validateEmailAvailableForChange(user, newEmail);
String normalizedEmail = newEmail.toLowerCase().trim();
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = passwordResetService.generateRawToken();
EmailChangeToken entity = new EmailChangeToken();
entity.setUser(user);
entity.setNewEmail(normalizedEmail);
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
+ "/bekrafta-epost?token="
+ rawToken;
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
return exposeToken ? Optional.of(rawToken) : Optional.empty();
}
@Transactional
public User confirmChange(String rawToken, String password) {
EmailChangeToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(EmailChangeTokenInvalidException::new);
User user = token.getUser();
userService.authenticate(user.getEmail(), password);
User updated = userService.applyEmailChange(user, token.getNewEmail());
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
return updated;
}
}

View file

@ -1,163 +0,0 @@
package se.bilhalsning.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class EmailService {
private final JavaMailSender mailSender;
@Value("${spring.mail.host:}")
private String mailHost;
@Value("${app.mail.from:noreply@bilhej.se}")
private String mailFrom;
public EmailService(@Autowired(required = false) JavaMailSender mailSender) {
this.mailSender = mailSender;
}
public void sendPasswordResetEmail(String toEmail, String resetUrl) {
String subject = "Återställ ditt lösenord BilHej";
String body = """
Hej,
Du har begärt att återställa lösenordet för ditt BilHej-konto.
Öppna länken nedan för att välja ett nytt lösenord (giltig i 1 timme):
%s
Om du inte begärde detta kan du ignorera det här meddelandet.
Vänliga hälsningar,
BilHej
""".formatted(resetUrl);
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Password reset link for {}: {}", toEmail, resetUrl);
return;
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(toEmail);
message.setSubject(subject);
message.setText(body);
try {
mailSender.send(message);
} catch (MailException ex) {
log.error("Failed to send password reset email to {}", toEmail, ex);
throw new IllegalStateException("Kunde inte skicka e-post just nu");
}
}
public void sendEmailChangeConfirmation(String toEmail, String confirmUrl) {
String subject = "Bekräfta din nya e-postadress BilHej";
String body = """
Hej,
Du har begärt att byta e-postadress för ditt BilHej-konto.
Öppna länken nedan och ange ditt lösenord för att bekräfta den nya adressen (giltig i 24 timmar):
%s
Om du inte begärde detta kan du ignorera det här meddelandet.
Vänliga hälsningar,
BilHej
""".formatted(confirmUrl);
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Email change confirmation link for {}: {}", toEmail, confirmUrl);
return;
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(toEmail);
message.setSubject(subject);
message.setText(body);
try {
mailSender.send(message);
} catch (MailException ex) {
log.error("Failed to send email change confirmation to {}", toEmail, ex);
throw new IllegalStateException("Kunde inte skicka e-post just nu");
}
}
public void sendOrderProcessingEmail(String toEmail, String plate, String ordersUrl) {
String subject = "Din beställning hanteras BilHej";
String body = """
Hej,
Tack för din betalning! Vi har tagit emot din beställning för fordonet %s och börjar hantera brevet.
Du kan följa status dina beställningar här:
%s
Vänliga hälsningar,
BilHej
""".formatted(plate, ordersUrl);
sendPlainText(toEmail, subject, body);
}
public void sendOrderSentEmail(String toEmail, String plate, String trackingId, String trackingUrl) {
String subject = "Ditt brev är skickat BilHej";
String body = """
Hej,
Ditt brev till fordonet %s har skickats med PostNord.
Spårnings-ID: %s
Spåra brevet: %s
Vänliga hälsningar,
BilHej
""".formatted(plate, trackingId, trackingUrl);
sendPlainText(toEmail, subject, body);
}
public void sendOrderFailedEmail(String toEmail, String plate, String ordersUrl) {
String subject = "Din beställning kunde inte slutföras BilHej";
String body = """
Hej,
Tyvärr kunde vi inte slutföra din beställning för fordonet %s. Vi återkommer om återbetalning behövs.
Se dina beställningar här:
%s
Vänliga hälsningar,
BilHej
""".formatted(plate, ordersUrl);
sendPlainText(toEmail, subject, body);
}
private void sendPlainText(String toEmail, String subject, String body) {
if (mailHost == null || mailHost.isBlank() || mailSender == null) {
log.info("SMTP not configured. Email to {} — subject: {}", toEmail, subject);
return;
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(toEmail);
message.setSubject(subject);
message.setText(body);
try {
mailSender.send(message);
} catch (MailException ex) {
log.error("Failed to send email to {}", toEmail, ex);
throw new IllegalStateException("Kunde inte skicka e-post just nu");
}
}
}

View file

@ -1,69 +0,0 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderNotificationService {
private final UserRepository userRepository;
private final EmailService emailService;
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
public void notifyOrderProcessing(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderProcessingEmail(
email,
order.getPlate(),
ordersPageUrl());
}
public void notifyOrderSent(Order order, String trackingId) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
String trackingUrl = "https://www.postnord.se/verktyg/spara/?id=" + trackingId;
emailService.sendOrderSentEmail(email, order.getPlate(), trackingId, trackingUrl);
}
public void notifyOrderFailed(Order order) {
String email = resolveCustomerEmail(order);
if (email.isBlank()) {
return;
}
emailService.sendOrderFailedEmail(email, order.getPlate(), ordersPageUrl());
}
private String resolveCustomerEmail(Order order) {
if (order.getUser() != null && order.getUser().getEmail() != null) {
return order.getUser().getEmail();
}
UUID userId = order.getUserId();
if (userId == null) {
return "";
}
return userRepository.findById(userId)
.map(User::getEmail)
.orElse("");
}
private String ordersPageUrl() {
String base = publicBaseUrl.endsWith("/")
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
: publicBaseUrl;
return base + "/mina-bestallningar";
}
}

View file

@ -1,78 +0,0 @@
package se.bilhalsning.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OrderNotificationService orderNotificationService;
public Order createOrder(UUID userId, String plate, String letterText) {
Order order = new Order();
order.setUserId(userId);
order.setPlate(plate.toUpperCase().trim());
order.setLetterText(letterText);
order.setStatus(OrderStatus.PENDING_PAYMENT);
return orderRepository.save(order);
}
public List<Order> getOrdersByUserId(UUID userId) {
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
public Order getOrderById(UUID id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public List<Order> getAllOrders() {
return orderRepository.findAllByOrderByCreatedAtDesc();
}
public Order confirmPayment(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.PROCESSING);
Order saved = orderRepository.save(order);
orderNotificationService.notifyOrderProcessing(saved);
return saved;
}
public Order cancelOrder(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.CANCELLED);
return orderRepository.save(order);
}
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setLetterText(letterText);
return orderRepository.save(order);
}
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (!order.getUserId().equals(userId)) {
throw new OrderNotFoundException(orderId);
}
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderStateException(
"Beställningen kan inte ändras i detta tillstånd");
}
return order;
}
}

View file

@ -1,85 +0,0 @@
package se.bilhalsning.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.PasswordResetToken;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.PasswordResetTokenInvalidException;
import se.bilhalsning.repository.PasswordResetTokenRepository;
@Service
@RequiredArgsConstructor
public class PasswordResetService {
private static final int TOKEN_BYTES = 32;
private static final long TOKEN_TTL_HOURS = 1;
private final UserService userService;
private final PasswordResetTokenRepository tokenRepository;
private final EmailService emailService;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${app.public-base-url:http://localhost:3000}")
private String publicBaseUrl;
@Value("${app.password-reset.expose-token:false}")
private boolean exposeToken;
@Transactional
public Optional<String> requestReset(String email) {
return userService.findByEmail(email).map(user -> {
tokenRepository.deleteUnusedByUserId(user.getId());
String rawToken = generateRawToken();
PasswordResetToken entity = new PasswordResetToken();
entity.setUser(user);
entity.setTokenHash(hashToken(rawToken));
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
tokenRepository.save(entity);
String resetUrl = publicBaseUrl.replaceAll("/$", "")
+ "/aterstall-losenord?token="
+ rawToken;
emailService.sendPasswordResetEmail(user.getEmail(), resetUrl);
return exposeToken ? Optional.of(rawToken) : Optional.<String>empty();
}).orElse(Optional.empty());
}
@Transactional
public void resetPassword(String rawToken, String newPassword) {
PasswordResetToken token = tokenRepository
.findByTokenHashAndUsedAtIsNull(hashToken(rawToken))
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(PasswordResetTokenInvalidException::new);
User user = token.getUser();
userService.updatePassword(user, newPassword);
token.setUsedAt(Instant.now());
tokenRepository.deleteUnusedByUserId(user.getId());
tokenRepository.save(token);
}
String generateRawToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
static String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
}

View file

@ -1,78 +0,0 @@
package se.bilhalsning.service;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email.toLowerCase().trim());
}
public User createUser(String email, String password) {
String normalizedEmail = email.toLowerCase().trim();
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
User user = new User();
user.setEmail(normalizedEmail);
user.setPasswordHash(passwordEncoder.encode(password));
return userRepository.save(user);
}
public User authenticate(String email, String password) {
String normalizedEmail = email.toLowerCase().trim();
User user = userRepository.findByEmail(normalizedEmail)
.orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
return user;
}
public void updatePassword(User user, String newPassword) {
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
}
public void changePassword(String email, String currentPassword, String newPassword) {
User user = findByEmail(email).orElseThrow(InvalidCredentialsException::new);
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
throw new InvalidCredentialsException();
}
updatePassword(user, newPassword);
}
public void validateEmailAvailableForChange(User user, String newEmail) {
String normalizedEmail = newEmail.toLowerCase().trim();
if (normalizedEmail.equals(user.getEmail())) {
throw new IllegalArgumentException("Ny e-postadress måste skilja sig från nuvarande");
}
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
}
public User applyEmailChange(User user, String newEmail) {
String normalizedEmail = newEmail.toLowerCase().trim();
if (normalizedEmail.equals(user.getEmail())) {
return user;
}
if (userRepository.existsByEmail(normalizedEmail)) {
throw new EmailAlreadyExistsException(normalizedEmail);
}
user.setEmail(normalizedEmail);
return userRepository.save(user);
}
}

View file

@ -1,141 +0,0 @@
package se.bilhalsning.service;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import se.bilhalsning.dto.VehicleInfoResponse;
import se.bilhalsning.exception.VehicleLookupException;
import se.bilhalsning.exception.VehicleNotFoundException;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class VehicleLookupService {
private static final Logger log = LoggerFactory.getLogger(VehicleLookupService.class);
private static final String BASE_URL = "https://biluppgifter.se/fordon";
public VehicleInfoResponse lookup(String plate) {
Document doc = fetchPage(plate);
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
if (fields.isEmpty() || !fields.containsKey("Fabrikat")) {
throw new VehicleNotFoundException(plate);
}
String make = fields.getOrDefault("Fabrikat", "");
String model = buildModel(fields);
int year = parseYearFromFields(fields);
String color = fields.getOrDefault("Färg", "");
String fuel = fields.getOrDefault("Bränsle", "");
return new VehicleInfoResponse(make, model, year, color, fuel);
}
Document fetchPage(String plate) {
String url = BASE_URL + "/" + plate.toLowerCase() + "/";
try {
return Jsoup.connect(url)
.userAgent("BilHej/1.0")
.timeout(10_000)
.get();
} catch (IOException e) {
log.warn("Failed to fetch vehicle data for plate {}", plate, e);
throw new VehicleLookupException("Failed to fetch vehicle data", e);
}
}
void extractSummaryFields(Document doc, Map<String, String> fields) {
Elements infoDivs = doc.select(".info > em");
for (Element em : infoDivs) {
Element span = em.nextElementSibling();
if (span != null && span.tagName().equals("span")) {
String label = span.text().trim();
String value = em.ownText().trim();
if (!label.isEmpty() && !value.isEmpty()) {
fields.putIfAbsent(label, value);
}
}
}
}
void extractDataSectionFields(Document doc, Map<String, String> fields) {
Element heading = doc.selectFirst("h2:containsOwn(Fordonsdata)");
if (heading == null) {
return;
}
Element section = findParentSection(heading);
if (section == null) {
return;
}
Elements listItems = section.select("ul.list > li");
for (Element li : listItems) {
Element labelEl = li.selectFirst("span.label");
Element valueEl = li.selectFirst("span.value");
if (labelEl != null && valueEl != null) {
String label = labelEl.text().trim();
String value = valueEl.text().trim();
if (!label.isEmpty() && !value.isEmpty() && !value.equals("Logga in")) {
fields.putIfAbsent(label, value);
}
}
}
}
Map<String, String> extractFields(Document doc) {
Map<String, String> fields = new LinkedHashMap<>();
extractSummaryFields(doc, fields);
extractDataSectionFields(doc, fields);
return fields;
}
private Element findParentSection(Element heading) {
Element current = heading.parent();
while (current != null) {
if ("section".equals(current.tagName())) {
return current;
}
current = current.parent();
}
return heading.parent();
}
private String buildModel(Map<String, String> fields) {
String model = fields.getOrDefault("Modell", "");
String variant = fields.getOrDefault("Variant", "");
if (!model.isEmpty() && !variant.isEmpty()) {
return model + " " + variant;
}
return model.isEmpty() ? variant : model;
}
private int parseYearFromFields(Map<String, String> fields) {
String yearText = fields.getOrDefault("Fordonsår / Modellår", "");
if (yearText.isEmpty()) {
yearText = fields.getOrDefault("Modellår", "");
}
if (yearText.isEmpty()) {
return 0;
}
String[] parts = yearText.split("/");
try {
return Integer.parseInt(parts[0].trim());
} catch (NumberFormatException e) {
return 0;
}
}
}

View file

@ -1,45 +0,0 @@
package se.bilhalsning.util;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
public final class PostNordTrackingNormalizer {
private PostNordTrackingNormalizer() {
}
public static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("Spårnings-ID krävs");
}
String trimmed = raw.trim();
if (trimmed.toLowerCase().contains("postnord")) {
String fromUrl = extractIdFromPostNordUrl(trimmed);
if (fromUrl != null && !fromUrl.isBlank()) {
trimmed = fromUrl;
}
}
return trimmed.replaceAll("\\s+", "");
}
private static String extractIdFromPostNordUrl(String url) {
try {
URI uri = URI.create(url);
String query = uri.getQuery();
if (query == null) {
return null;
}
for (String param : query.split("&")) {
if (param.startsWith("id=")) {
return URLDecoder.decode(param.substring(3), StandardCharsets.UTF_8).trim();
}
}
} catch (IllegalArgumentException ignored) {
return null;
}
return null;
}
}

View file

@ -1,37 +0,0 @@
spring:
flyway:
locations: classpath:db/migration,classpath:db/dev-migration
datasource:
url: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
driver-class-name: org.postgresql.Driver
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
h2:
console:
enabled: false
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
mail:
properties:
mail:
smtp:
auth: false
starttls:
enable: false
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET}
public-base-url: ${APP_PUBLIC_BASE_URL:http://frontend}
# E2E only: never enable in production (see application-prod.yml).
password-reset:
expose-token: true
email-change:
expose-token: true

View file

@ -1,21 +0,0 @@
spring:
flyway:
locations: classpath:db/migration
# Prod DBs created before seeds moved to db/dev-migration still list V2/V4/V6 in history.
ignore-migration-patterns: '*:missing'
# docker profile disables STARTTLS for Mailpit; prod must re-enable for Resend SMTP.
mail:
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
app:
admin:
email: ${ADMIN_EMAIL}
password: ${ADMIN_PASSWORD}
password-reset:
expose-token: false
email-change:
expose-token: false

View file

@ -1,47 +0,0 @@
server:
port: 8080
spring:
application:
name: BilHej
datasource:
url: jdbc:h2:mem:bilhej;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: validate
flyway:
enabled: true
locations: classpath:db/migration,classpath:db/dev-migration
mail:
host: ${MAIL_HOST:}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
app:
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost:3000}
mail:
from: ${MAIL_FROM:noreply@bilhej.se}
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -1,8 +0,0 @@
-- Dev/CI only: test user for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription)
VALUES (
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'test@bilhej.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none'
);

View file

@ -1,9 +0,0 @@
-- Dev/CI only: admin for local docker and e2e (password: test1234)
INSERT INTO users (id, email, password_hash, subscription, role)
VALUES (
'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
'admin@bilhalsning.se',
'$2b$12$18UFRDPgHWuw5FYeu6X1ReisFjjuxs5XxDafi6.wZbsywoU7vUaLG',
'none',
'admin'
);

View file

@ -1,6 +0,0 @@
-- Dev/CI only: sample orders for test@bilhej.se (id: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11)
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES
('c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'ABC123', 'Hej! Jag ville bara säga att du har en väldigt fin bil. Hälsningar från en bilentusiast!', 'sent', 49.00, 'PN123456789', TIMESTAMP '2026-05-11 12:00:00', TIMESTAMP '2026-05-13 12:00:00'),
('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'DEF456', 'Hej! Jag är intresserad av att köpa din bil. Kontakta mig gärna på test@example.com så kan vi diskutera ett pris.', 'pending_payment', NULL, NULL, TIMESTAMP '2026-05-14 13:00:00', TIMESTAMP '2026-05-14 13:00:00'),
('c3eebc99-9c0b-4ef8-bb6d-6bb9bd380a13', 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', 'GHI789', 'Hej! Jag noterade att ditt bakre högra hjul har lite för lågt lufttryck. Tänkte det kan vara bra att veta!', 'delivered', 49.00, 'PN987654321', TIMESTAMP '2026-05-07 10:00:00', TIMESTAMP '2026-05-12 10:00:00');

View file

@ -1,13 +0,0 @@
-- Dev/CI: order in "processing" for admin fulfillment testing
INSERT INTO orders (id, user_id, plate, letter_text, status, amount_paid, tracking_id, created_at, updated_at)
VALUES (
'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
'JKL012',
'Hej! Bara en påminnelse om serviceboken.',
'processing',
49.00,
NULL,
TIMESTAMP '2026-05-16 09:00:00',
TIMESTAMP '2026-05-16 09:00:00'
);

View file

@ -1,14 +0,0 @@
CREATE TABLE email_change_tokens (
id UUID NOT NULL,
user_id UUID NOT NULL,
new_email VARCHAR(255) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_email_change_tokens PRIMARY KEY (id),
CONSTRAINT fk_email_change_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_email_change_tokens_user_id ON email_change_tokens (user_id);
CREATE INDEX idx_email_change_tokens_token_hash ON email_change_tokens (token_hash);

View file

@ -1,3 +0,0 @@
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE orders ADD COLUMN admin_notes TEXT;

View file

@ -1,11 +0,0 @@
CREATE TABLE users (
id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
subscription VARCHAR(20) NOT NULL DEFAULT 'none',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_users PRIMARY KEY (id),
CONSTRAINT uq_users_email UNIQUE (email),
CONSTRAINT ck_users_subscription CHECK (subscription IN ('none', 'basic', 'pro'))
);

View file

@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';

View file

@ -1,17 +0,0 @@
CREATE TABLE orders (
id UUID NOT NULL,
user_id UUID NOT NULL,
plate VARCHAR(10) NOT NULL,
letter_text TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending_payment',
amount_paid DECIMAL(10,2),
tracking_id VARCHAR(100),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'processing', 'sent', 'delivered', 'failed'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

View file

@ -1,13 +0,0 @@
CREATE TABLE password_reset_tokens (
id UUID NOT NULL,
user_id UUID NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_password_reset_tokens PRIMARY KEY (id),
CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens (user_id);
CREATE INDEX idx_password_reset_tokens_token_hash ON password_reset_tokens (token_hash);

View file

@ -1,14 +0,0 @@
ALTER TABLE orders DROP CONSTRAINT ck_orders_status;
ALTER TABLE orders
ADD CONSTRAINT ck_orders_status CHECK (
status IN (
'pending_payment',
'paid',
'processing',
'sent',
'delivered',
'failed',
'cancelled'
)
);

View file

@ -1,13 +0,0 @@
package se.bilhalsning;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class BilHejApplicationTests {
@Test
void contextLoads() {
}
}

View file

@ -1,45 +0,0 @@
package se.bilhalsning;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
class FlywayMigrationFilesTest {
private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__.+\\.sql$");
private static final Path MIGRATION_DIR = Path.of("src/main/resources/db/migration");
@Test
void shouldUseUniqueVersionNumbersAndValidNames() throws IOException {
assertTrue(
Files.isDirectory(MIGRATION_DIR),
() -> "Expected migration directory at " + MIGRATION_DIR.toAbsolutePath());
Set<Integer> versions = new HashSet<>();
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
for (Path file : files.filter(path -> path.getFileName().toString().endsWith(".sql")).toList()) {
String name = file.getFileName().toString();
Matcher matcher = VERSION_PATTERN.matcher(name);
assertTrue(matcher.matches(), () -> "Invalid migration filename: " + name);
int version = Integer.parseInt(matcher.group(1));
assertFalse(
versions.contains(version),
() -> "Duplicate Flyway version V" + version + " in " + MIGRATION_DIR);
versions.add(version);
}
}
assertFalse(versions.isEmpty(), "Expected at least one schema migration");
}
}

View file

@ -1,81 +0,0 @@
package se.bilhalsning;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import se.bilhalsning.entity.Subscription;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@SpringBootTest
@Transactional
class FlywayMigrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldPersistAndRetrieveUser() {
User user = new User();
user.setEmail("test@example.com");
user.setPasswordHash("hash");
user.setSubscription(Subscription.BASIC);
userRepository.saveAndFlush(user);
User found = userRepository.findById(user.getId()).orElseThrow();
assertEquals(user.getId(), found.getId());
assertEquals("test@example.com", found.getEmail());
assertEquals("hash", found.getPasswordHash());
assertEquals(Subscription.BASIC, found.getSubscription());
assertNotNull(found.getCreatedAt());
assertNotNull(found.getUpdatedAt());
}
@Test
void shouldEnforceUniqueEmail() {
User first = new User();
first.setEmail("unique@example.com");
first.setPasswordHash("hash");
userRepository.saveAndFlush(first);
User duplicate = new User();
duplicate.setEmail("unique@example.com");
duplicate.setPasswordHash("hash");
assertThrows(DataIntegrityViolationException.class, () -> {
userRepository.saveAndFlush(duplicate);
});
}
@Test
void shouldPersistAllSubscriptionValues() {
for (Subscription sub : Subscription.values()) {
User user = new User();
user.setEmail(sub.getValue() + "@example.com");
user.setPasswordHash("hash");
user.setSubscription(sub);
userRepository.saveAndFlush(user);
User found = userRepository.findByEmail(sub.getValue() + "@example.com").orElseThrow();
assertEquals(sub, found.getSubscription());
}
}
@Test
void shouldDefaultSubscriptionToNone() {
User user = new User();
user.setEmail("default@example.com");
user.setPasswordHash("hash");
userRepository.saveAndFlush(user);
User found = userRepository.findByEmail("default@example.com").orElseThrow();
assertEquals(Subscription.NONE, found.getSubscription());
}
}

View file

@ -1,72 +0,0 @@
package se.bilhalsning.config;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.boot.DefaultApplicationArguments;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
import se.bilhalsning.entity.User;
import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
class AdminBootstrapTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private AdminBootstrap adminBootstrap;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(adminBootstrap, "adminEmail", "admin@bilhej.se");
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "secure-production-password");
}
@Test
void shouldSkipBootstrapWhenAdminAlreadyExists() {
when(userRepository.existsByRole("admin")).thenReturn(true);
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldCreateAdminWhenMissing() {
when(userRepository.existsByRole("admin")).thenReturn(false);
when(passwordEncoder.encode("secure-production-password")).thenReturn("encoded-hash");
adminBootstrap.run(new DefaultApplicationArguments(new String[] {}));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
User saved = captor.getValue();
org.junit.jupiter.api.Assertions.assertEquals("admin@bilhej.se", saved.getEmail());
org.junit.jupiter.api.Assertions.assertEquals("encoded-hash", saved.getPasswordHash());
org.junit.jupiter.api.Assertions.assertEquals("admin", saved.getRole());
}
@Test
void shouldFailWhenCredentialsMissingAndNoAdmin() {
ReflectionTestUtils.setField(adminBootstrap, "adminPassword", "");
when(userRepository.existsByRole("admin")).thenReturn(false);
org.junit.jupiter.api.Assertions.assertThrows(
IllegalStateException.class,
() -> adminBootstrap.run(new DefaultApplicationArguments(new String[] {})));
}
}

View file

@ -1,33 +0,0 @@
package se.bilhalsning.config;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.mockito.InOrder;
@SpringBootTest(
properties = {
"app.admin.email=admin@test.se",
"app.admin.password=test-password",
})
@ActiveProfiles("prod")
class ProdFlywayConfigTest {
@Autowired
private FlywayMigrationStrategy flywayMigrationStrategy;
@Test
void shouldRepairBeforeMigrateOnProd() {
Flyway flyway = mock(Flyway.class);
flywayMigrationStrategy.migrate(flyway);
InOrder order = inOrder(flyway);
order.verify(flyway).repair();
order.verify(flyway).migrate();
}
}

View file

@ -1,44 +0,0 @@
package se.bilhalsning.config;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import se.bilhalsning.security.JwtService;
@SpringBootTest
class SecurityConfigTest {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private SecurityFilterChain securityFilterChain;
@Autowired
private JwtService jwtService;
@Test
void shouldProvideBcryptPasswordEncoder() {
assertNotNull(passwordEncoder);
assertInstanceOf(BCryptPasswordEncoder.class, passwordEncoder);
}
@Test
void shouldProvideSecurityFilterChain() {
assertNotNull(securityFilterChain);
assertDoesNotThrow(() -> securityFilterChain.getFilters());
assertNotNull(securityFilterChain.getFilters());
}
@Test
void shouldExposeJwtServiceAsBean() {
assertNotNull(jwtService);
}
}

View file

@ -1,178 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.AdminOrderWorkflowService;
import se.bilhalsning.service.OrderService;
@SpringBootTest
@AutoConfigureMockMvc
class AdminControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private AdminOrderWorkflowService adminOrderWorkflowService;
@Test
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "test@bilhej.se", roles = "USER")
void shouldReturn403ForNonAdminUser() throws Exception {
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturnAllOrdersForAdmin() throws Exception {
Order order = createOrder(UUID.randomUUID(), "ABC123", "test@bilhej.se", OrderStatus.SENT);
when(orderService.getAllOrders()).thenReturn(List.of(order));
mockMvc.perform(get("/api/admin/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").value(order.getId().toString()))
.andExpect(jsonPath("$[0].email").value("test@bilhej.se"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].status").value("sent"))
.andExpect(jsonPath("$[0].allowedStatuses").isArray())
.andExpect(jsonPath("$[0].canRegisterShipment").value(true));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateOrderStatusSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.FAILED);
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("failed")))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"failed\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("failed"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn409WhenStatusTransitionInvalid() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(adminOrderWorkflowService.updateOrderStatus(eq(orderId), eq("delivered")))
.thenThrow(new InvalidOrderStateException("Ogiltig övergång"));
mockMvc.perform(patch("/api/admin/orders/{id}/status", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"delivered\"}"))
.andExpect(status().isConflict());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldRegisterShipmentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.SENT);
order.setTrackingId("PN123456789");
order.setShippedAt(Instant.parse("2026-05-13T12:00:00Z"));
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123456789"), eq(true)))
.thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123456789\",\"notifyCustomer\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.trackingId").value("PN123456789"))
.andExpect(jsonPath("$.status").value("sent"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn400WhenTrackingInputBlank() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldUpdateAdminNotes() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = createOrder(orderId, "ABC123", "test@bilhej.se", OrderStatus.PROCESSING);
order.setAdminNotes("Kontaktat TS");
when(adminOrderWorkflowService.updateAdminNotes(orderId, "Kontaktat TS")).thenReturn(order);
mockMvc.perform(patch("/api/admin/orders/{id}/notes", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"adminNotes\":\"Kontaktat TS\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.adminNotes").value("Kontaktat TS"));
}
@Test
@WithMockUser(username = "admin@bilhalsning.se", roles = "ADMIN")
void shouldReturn404WhenOrderNotFoundForRegisterShipment() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(adminOrderWorkflowService.registerShipment(eq(orderId), eq("PN123"), anyBoolean()))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(patch("/api/admin/orders/{id}/register-shipment", orderId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"trackingInput\":\"PN123\"}"))
.andExpect(status().isNotFound());
}
private Order createOrder(UUID orderId, String plate, String email, OrderStatus status) {
User user = new User();
user.setEmail(email);
Order order = new Order();
order.setId(orderId);
order.setUser(user);
order.setPlate(plate);
order.setLetterText("Test letter");
order.setStatus(status);
order.setAmountPaid(new BigDecimal("49.00"));
return order;
}
}

View file

@ -1,270 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.LoginRequest;
import se.bilhalsning.dto.RegisterRequest;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.security.JwtService;
import java.util.Optional;
import se.bilhalsning.service.EmailChangeService;
import se.bilhalsning.service.PasswordResetService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
private UserService userService;
@MockitoBean
private PasswordResetService passwordResetService;
@MockitoBean
private EmailChangeService emailChangeService;
@MockitoBean
private JwtService jwtService;
@Test
void shouldReturn201AndTokenWhenRegisterSucceeds() throws Exception {
when(userService.createUser("new@example.com", "password123")).thenReturn(null);
when(jwtService.generateToken("new@example.com", "user")).thenReturn("test-jwt-token");
RegisterRequest request = new RegisterRequest("new@example.com", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.token").value("test-jwt-token"));
}
@Test
void shouldReturn409WhenEmailAlreadyExists() throws Exception {
when(userService.createUser("taken@example.com", "password123"))
.thenThrow(new EmailAlreadyExistsException("taken@example.com"));
RegisterRequest request = new RegisterRequest("taken@example.com", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value("E-postadressen är redan registrerad"));
}
@Test
void shouldReturn400WhenEmailIsInvalid() throws Exception {
RegisterRequest request = new RegisterRequest("not-an-email", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenPasswordIsTooShort() throws Exception {
RegisterRequest request = new RegisterRequest("new@example.com", "1234567");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenEmailIsMissing() throws Exception {
RegisterRequest request = new RegisterRequest("", "password123");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception {
User user = new User();
user.setEmail("user@example.com");
user.setRole("user");
when(userService.authenticate("user@example.com", "password123")).thenReturn(user);
when(jwtService.generateToken("user@example.com", "user")).thenReturn("login-jwt-token");
LoginRequest request = new LoginRequest("user@example.com", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("login-jwt-token"));
}
@Test
void shouldReturnAdminRoleInTokenWhenAdminLogsIn() throws Exception {
User admin = new User();
admin.setEmail("admin@bilhalsning.se");
admin.setRole("admin");
when(userService.authenticate("admin@bilhalsning.se", "admin1234")).thenReturn(admin);
when(jwtService.generateToken("admin@bilhalsning.se", "admin")).thenReturn("admin-jwt-token");
LoginRequest request = new LoginRequest("admin@bilhalsning.se", "admin1234");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("admin-jwt-token"));
}
@Test
void shouldReturn401WhenCredentialsAreInvalid() throws Exception {
when(userService.authenticate("user@example.com", "wrongpassword"))
.thenThrow(new InvalidCredentialsException());
LoginRequest request = new LoginRequest("user@example.com", "wrongpassword");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord"));
}
@Test
void shouldReturn400WhenLoginEmailIsBlank() throws Exception {
LoginRequest request = new LoginRequest("", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginPasswordIsBlank() throws Exception {
LoginRequest request = new LoginRequest("user@example.com", "");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn400WhenLoginEmailIsInvalid() throws Exception {
LoginRequest request = new LoginRequest("not-an-email", "password123");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
void shouldReturn200WhenForgotPasswordRequested() throws Exception {
when(passwordResetService.requestReset("user@example.com")).thenReturn(Optional.empty());
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message")
.value("Om e-postadressen finns har vi skickat instruktioner för att återställa lösenordet."))
.andExpect(jsonPath("$.testToken").doesNotExist());
verify(passwordResetService).requestReset("user@example.com");
}
@Test
void shouldIncludeTestTokenWhenServiceReturnsToken() throws Exception {
when(passwordResetService.requestReset("user@example.com"))
.thenReturn(Optional.of("e2e-reset-token"));
mockMvc.perform(post("/api/auth/forgot-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"user@example.com\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.testToken").value("e2e-reset-token"));
}
@Test
void shouldReturn200WhenResetPasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/reset-password")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\",\"password\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats. Du kan nu logga in."));
}
@Test
@WithMockUser(username = "admin@bilhej.se")
void shouldReturn200WhenChangePasswordSucceeds() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Lösenordet har uppdaterats."));
}
@Test
void shouldRejectChangePasswordWithoutAuth() throws Exception {
mockMvc.perform(post("/api/auth/change-password")
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"currentPassword\":\"test1234\",\"newPassword\":\"newpassword123\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "user@example.com")
void shouldReturn200WhenChangeEmailRequestSucceeds() throws Exception {
when(emailChangeService.requestChange("user@example.com", "password123", "new@example.com"))
.thenReturn(Optional.of("test-token"));
mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message")
.value("Vi har skickat en bekräftelselänk till din nya e-postadress."))
.andExpect(jsonPath("$.testToken").value("test-token"));
}
@Test
void shouldReturn200AndNewTokenWhenConfirmEmailChangeSucceeds() throws Exception {
User user = new User();
user.setEmail("new@example.com");
user.setRole("user");
when(emailChangeService.confirmChange("confirm-token", "password123")).thenReturn(user);
when(jwtService.generateToken("new@example.com", "user")).thenReturn("new-jwt-token");
mockMvc.perform(post("/api/auth/confirm-email-change")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"confirm-token\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("new-jwt-token"));
}
@Test
void shouldRejectChangeEmailWithoutAuth() throws Exception {
mockMvc.perform(post("/api/auth/change-email")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"newEmail\":\"new@example.com\",\"password\":\"password123\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
}

View file

@ -1,306 +0,0 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.User;
import se.bilhalsning.security.JwtService;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
class OrderControllerTest {
private static final String TEST_SECRET =
"this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
@Autowired
private MockMvc mockMvc;
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn401WhenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrdersForAuthenticatedUser() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturnOrderWithAllFields() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.SENT);
order.setTrackingId("PN123456789");
when(orderService.getOrdersByUserId(userId)).thenReturn(List.of(order));
mockMvc.perform(get("/api/orders"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$[0].plate").value("ABC123"))
.andExpect(jsonPath("$[0].letterText").value("Test letter"))
.andExpect(jsonPath("$[0].status").value("sent"))
.andExpect(jsonPath("$[0].trackingId").value("PN123456789"));
}
@Test
@WithMockUser(username = "unknown@example.com")
void shouldReturn401WhenUserNotFound() throws Exception {
when(userService.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldReturn401WhenPostingWithoutAuth() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithSwedishMessageWhenTokenExpired() throws Exception {
JwtService expiredJwtService = new JwtService(TEST_SECRET, -1000L);
String expiredToken = expiredJwtService.generateToken("test@bilhej.se");
mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message").exists());
}
@Test
void shouldReturn401WithMessageWhenNoAuthHeader() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.message")
.value(org.hamcrest.Matchers.containsString("session")));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCreateOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order savedOrder = new se.bilhalsning.entity.Order();
savedOrder.setId(UUID.fromString("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"));
savedOrder.setUserId(userId);
savedOrder.setPlate("ABC123");
savedOrder.setLetterText("Hej fin bil!");
savedOrder.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.createOrder(userId, "ABC123", "Hej fin bil!"))
.thenReturn(savedOrder);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"Hej fin bil!\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("d1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.letterText").value("Hej fin bil!"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectInvalidPlateFormat() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"INVALID\",\"letterText\":\"Hej\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Ogiltigt registreringsnummer")));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectEmptyLetterText() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectLetterTextOver1000Chars() throws Exception {
String longText = "a".repeat(1001);
mockMvc.perform(post("/api/orders")
.contentType("application/json")
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldGetSingleOrderForOwner() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(UUID.randomUUID());
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldPatchOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Updated text");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"Updated text\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.letterText").value("Updated text"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectPatchWithEmptyLetterText() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCancelOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("cancelled"));
}
}

Some files were not shown because too many files have changed in this diff Show more