Compare commits
153 commits
f2c1a9e2d6
...
d7739bcd58
| Author | SHA1 | Date | |
|---|---|---|---|
| d7739bcd58 | |||
|
|
4b35d8ff30 | ||
| c88fa142d3 | |||
| 81e3968e31 | |||
| 5335ba4f12 | |||
|
|
3d2db1471f | ||
|
|
da54a67d9d | ||
| aa2cb7c4a0 | |||
| 737bc3dc64 | |||
| fa7e48fe02 | |||
| c7eeaf6a6b | |||
| 0b2c58fa82 | |||
| aec7020621 | |||
| 623433ba4d | |||
| c578463b10 | |||
| afa552e18b | |||
| 2fa161f4fa | |||
| 5b5b44194d | |||
| 1c9269699e | |||
| 17fe67ae3f | |||
| 144791b7e6 | |||
| cf938501c5 | |||
| 4d3beeffb4 | |||
| 7a95c1423c | |||
| 71a3225a11 | |||
| b2aaeb5733 | |||
| 3532e4d486 | |||
| 3f20656f04 | |||
| a12e07ec1c | |||
| ec62ba7673 | |||
| 258f6f5a17 | |||
| bce2447238 | |||
| c0c32b718b | |||
| 255095e6bd | |||
| c0902d0494 | |||
| 081a1f90d3 | |||
| 162002dfb1 | |||
| 60cb07fc89 | |||
| 758ace1b92 | |||
| 139250b2ac | |||
| aa2b4b4a16 | |||
| 15d7b4ae4c | |||
| 41cfea09a6 | |||
| ca5ce12812 | |||
| 3d0b7fe799 | |||
| 082139d266 | |||
| de86880a8f | |||
| ad195fd890 | |||
| 86fb946e33 | |||
| 45b2449b14 | |||
| fb9713d8d8 | |||
| 61a7b8a40c | |||
| db56fc58de | |||
| d652a5b862 | |||
| 7731eb1155 | |||
| 49b5a91f4a | |||
| ec122e86b8 | |||
| 350dfcfd7b | |||
| 93ece8128a | |||
| 75911dfffa | |||
| 4385f43b08 | |||
| ab1cdb358f | |||
| b27b1395f7 | |||
| 3ba7560f82 | |||
| 01db53860b | |||
| b9a0bdb318 | |||
| dfb3e0dedc | |||
| e2bccb4029 | |||
| 0e7dbb915e | |||
| e4de2a316a | |||
| dfcc8e37c6 | |||
| d078b9e125 | |||
| 5eb49c05a8 | |||
| 7938a1620b | |||
| 0137a5005b | |||
| 13974e26f7 | |||
| 5705b17c4b | |||
| 4a48dccd91 | |||
| 3e014b90ae | |||
| df7cf9f020 | |||
| 828dd82dd3 | |||
| 0f613b21a6 | |||
| 98d5545be0 | |||
| e8530b8d95 | |||
| 5abb5bc2e9 | |||
| 1f1016a775 | |||
| 8e3632f05f | |||
| 10cc12154e | |||
| e4cfb873f0 | |||
| b41124b141 | |||
| 076fe1b299 | |||
| 3cc0cb88d2 | |||
| 0be3bc473d | |||
| 8892e0402b | |||
| df539f7cb7 | |||
| be7775f680 | |||
| 1b87e15a21 | |||
| 3792fdec82 | |||
| 18f462c5c1 | |||
| 6dc9b6de33 | |||
| 2506a0283c | |||
| 851cd8afa0 | |||
| 00327674ed | |||
| 8cd7991603 | |||
| c3c1513ac1 | |||
| d27bde2fbe | |||
| 744ff00b9d | |||
| 00ada956bf | |||
| 0f34d29a2a | |||
| dcc466439e | |||
| ebab892e93 | |||
| f6825ec885 | |||
| 3fa4f6831e | |||
| 7e6124ce4a | |||
| e654d42a4f | |||
| fc5e9ddda7 | |||
| 668cd023be | |||
| 9b4f08469c | |||
| 5df7c97977 | |||
| 76028fa94d | |||
| 8217b9c038 | |||
| fefdea089d | |||
| 96508d63cd | |||
| 6ab5e2f707 | |||
| 5fa903d9af | |||
| 55f0fd8771 | |||
| 0c62d7e60a | |||
| 32b315654e | |||
| a74bb89824 | |||
| 6f23368749 | |||
| 0d7e672bc3 | |||
| 8d07bb7ab1 | |||
| 8a95483fb8 | |||
| bb4bb0c6c6 | |||
| ca21c5b659 | |||
| e05f74bd82 | |||
| 491dc99c55 | |||
| 3d4a6daee9 | |||
| 8e495672d3 | |||
| c7d443f236 | |||
| d70196112d | |||
| 4c6094446b | |||
| 210ac87ede | |||
| 078f07f2ac | |||
| ce95a451ce | |||
| 0d9baeb6e5 | |||
| c6e2e509eb | |||
| c03b5a1401 | |||
| 4d449d54d0 | |||
| 9931061cb6 | |||
| 83b578ca22 | |||
| 524242bbdb | |||
| a8ee1edaf0 |
256 changed files with 28022 additions and 95 deletions
66
.dockerignore
Normal file
66
.dockerignore
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 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
|
||||
52
.env.example
Normal file
52
.env.example
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 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
|
||||
123
.forgejo/workflows/ci.yml
Normal file
123
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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
|
||||
144
.forgejo/workflows/deploy.yml
Normal file
144
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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
6
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ target/
|
|||
*.jar
|
||||
*.war
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
|
@ -37,6 +38,11 @@ Thumbs.db
|
|||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
certs/
|
||||
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Java
|
||||
*.hprof
|
||||
|
|
|
|||
158
AGENTS.md
158
AGENTS.md
|
|
@ -15,7 +15,7 @@ the recipient's name or address.
|
|||
integration yet. Owner address is obtained manually by a human and entered into
|
||||
the admin panel.
|
||||
|
||||
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 3 backend +
|
||||
Tech stack: Vue.js 3 (Vite, Pinia) frontend + Java 21 Spring Boot 4 backend +
|
||||
PostgreSQL 16. Deployed via Docker Compose.
|
||||
|
||||
---
|
||||
|
|
@ -24,11 +24,24 @@ PostgreSQL 16. Deployed via Docker Compose.
|
|||
|
||||
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)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # first time only, then fill in keys
|
||||
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)
|
||||
|
|
@ -40,15 +53,14 @@ npm run dev # dev server on :3000 with HMR
|
|||
npm run build # production build
|
||||
npm run lint # ESLint
|
||||
npm run test # vitest
|
||||
npm run test:coverage # vitest with coverage (HTML at frontend/coverage/)
|
||||
```
|
||||
|
||||
### Backend (Spring Boot 3 + Java 21)
|
||||
### Backend (Spring Boot 4 + Java 21)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./mvnw spring-boot:run # dev server on :8080
|
||||
./mvnw test # JUnit 5 + Mockito
|
||||
./mvnw verify # full verification including integration tests
|
||||
./gradlew :backend:bootRun # dev server on :8080
|
||||
./gradlew :backend:test # JUnit 5 + Mockito (backend only)
|
||||
```
|
||||
|
||||
### Stripe webhooks (local testing)
|
||||
|
|
@ -62,8 +74,20 @@ stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
|||
Flyway migrations run automatically on Spring Boot startup. Migration files
|
||||
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`.
|
||||
|
||||
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
|
||||
|
|
@ -74,13 +98,14 @@ bilhej/
|
|||
│ ├── src/
|
||||
│ │ ├── pages/ # Route-level page components
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── composables/ # useXxx.js shared logic
|
||||
│ │ ├── composables/ # useXxx.ts shared logic
|
||||
│ │ ├── stores/ # Pinia stores
|
||||
│ │ ├── api/ # API client modules
|
||||
│ │ ├── router/ # Vue Router config
|
||||
│ │ └── assets/ # Static files, CSS
|
||||
│ └── ...
|
||||
├── backend/ # Spring Boot 3 (Java 21)
|
||||
├── backend/ # Spring Boot 4 (Java 21) — Gradle subproject
|
||||
│ ├── build.gradle # Spring Boot plugin, Java deps, test config
|
||||
│ ├── src/main/java/se/bilhalsning/
|
||||
│ │ ├── config/ # @Configuration classes
|
||||
│ │ ├── controller/ # REST controllers
|
||||
|
|
@ -92,11 +117,23 @@ bilhej/
|
|||
│ │ ├── exception/ # Custom exceptions + @ControllerAdvice
|
||||
│ │ └── mapper/ # Entity ↔ DTO mapping
|
||||
│ └── src/main/resources/
|
||||
│ ├── application.yml
|
||||
│ ├── application.yml # default (H2, IDE dev)
|
||||
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
||||
│ └── db/migration/ # Flyway migrations
|
||||
├── docker/ # Dockerfiles
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.prod.yml
|
||||
│ ├── backend.Dockerfile # dev: JDK 21 + gradle :backend:bootRun
|
||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle build → JRE Alpine, non-root)
|
||||
│ ├── 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
|
||||
├── AGENTS.md # This file
|
||||
├── README.md
|
||||
|
|
@ -119,6 +156,29 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|||
- Create `feature/*`, `fix/*`, or `chore/*` branches from `develop`.
|
||||
- Never commit directly to `master` or `develop`.
|
||||
- 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)
|
||||
- `<script setup>` with Composition API only. Never Options API.
|
||||
|
|
@ -126,12 +186,12 @@ Full details in `@CODING_GUIDELINES.md`. Key rules:
|
|||
- API calls live in `api/` modules, never in components.
|
||||
- Component styles are scoped.
|
||||
|
||||
### Backend (Spring Boot 3)
|
||||
### Backend (Spring Boot 4)
|
||||
- Constructor injection with `@RequiredArgsConstructor`. No `@Autowired`.
|
||||
- DTOs: prefer Java records. No bare entities in responses.
|
||||
- Controllers stay thin. All logic in services.
|
||||
- Use `@ControllerAdvice` for consistent error responses (`{ "message": "..." }`).
|
||||
- No Lombok beyond `@RequiredArgsConstructor`.
|
||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||
|
||||
### Database
|
||||
- Table names: snake_case, plural. PKs: UUID, generated in code.
|
||||
|
|
@ -155,6 +215,15 @@ 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
|
||||
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
|
||||
Always verify `stripe-signature` header using `STRIPE_WEBHOOK_SECRET`.
|
||||
Webhook endpoint is public (no auth). Without signature verification an
|
||||
|
|
@ -174,6 +243,10 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
|||
|
||||
## 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
|
||||
- JUnit 5 + Mockito for service layer tests.
|
||||
- `@WebMvcTest` for controller tests.
|
||||
|
|
@ -184,10 +257,65 @@ public vehicle info) must be excluded from the Spring Security filter chain.
|
|||
### Frontend
|
||||
- Vitest for composables and utility functions.
|
||||
- Component tests with Vue Test Utils where needed.
|
||||
- E2E tests deferred to Phase 1.
|
||||
- E2E tests with Playwright in `frontend/e2e/`.
|
||||
|
||||
### 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)
|
||||
- `./mvnw verify` and `npm run test && npm run lint` must pass before merge.
|
||||
- `./gradlew check` 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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,15 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -85,9 +94,9 @@ Types: `feat`, `fix`, `refactor`, `chore`, `docs`, `test`, `style`
|
|||
|--------------|-----------------------------|----------------------------------|
|
||||
| Page | PascalCase, in `pages/` | `HomePage.vue`, `OrderHistoryPage.vue` |
|
||||
| Component | PascalCase, in `components/`| `PlateInput.vue`, `LetterPreview.vue` |
|
||||
| Composable | camelCase, `use` prefix | `useAuth.js`, `usePayment.js` |
|
||||
| Store | camelCase, in `stores/` | `authStore.js`, `orderStore.js` |
|
||||
| API module | camelCase, in `api/` | `orders.js`, `templates.js` |
|
||||
| Composable | camelCase, `use` prefix | `useAuth.ts`, `usePayment.ts` |
|
||||
| Store | camelCase, in `stores/` | `authStore.ts`, `orderStore.ts` |
|
||||
| API module | camelCase, in `api/` | `orders.ts`, `templates.ts` |
|
||||
|
||||
### Component Structure
|
||||
|
||||
|
|
@ -140,12 +149,12 @@ async function handleSubmit() {
|
|||
- `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.
|
||||
- Prefer Pinia stores over prop drilling for shared state (auth, current order).
|
||||
- API calls live in `api/*.js` modules, not in components.
|
||||
- API calls live in `api/*.ts` modules, not in components.
|
||||
- Use `fetch` or `axios` via a single configured instance (base URL, auth header interceptor).
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend — Spring Boot 3
|
||||
## 4. Backend — Spring Boot 4
|
||||
|
||||
### Package Structure
|
||||
|
||||
|
|
@ -209,7 +218,7 @@ public class OrderController {
|
|||
- All responses: `ResponseEntity<T>`. Never return bare entities.
|
||||
- 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/`.
|
||||
- No Lombok beyond `@RequiredArgsConstructor`. Prefer explicit getters/setters or records.
|
||||
- Lombok: `@RequiredArgsConstructor`, `@Getter`, `@Setter`, `@NoArgsConstructor` are all fine. Prefer records for DTOs.
|
||||
|
||||
### API Path Conventions
|
||||
|
||||
|
|
@ -241,7 +250,7 @@ GET /api/vehicles/{plate} Get public vehicle info
|
|||
### Frontend
|
||||
|
||||
```javascript
|
||||
// api/client.js — centralized fetch wrapper
|
||||
// api/client.ts — centralized fetch wrapper
|
||||
async function request(url, options) {
|
||||
const response = await fetch(`${BASE_URL}${url}`, {
|
||||
...options,
|
||||
|
|
@ -289,10 +298,37 @@ public class GlobalExceptionHandler {
|
|||
|
||||
## 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`.
|
||||
- Frontend: Vitest for composables and utility functions. Cypress or Playwright for E2E (Phase 1).
|
||||
- Frontend: Vitest for composables and utility functions. Component tests with Vue Test Utils.
|
||||
- E2E: Playwright (`npm run test:e2e`). Tests in `frontend/e2e/`. Requires `docker compose up`.
|
||||
- Test naming: `shouldXxxWhenYyy` — e.g., `shouldReturn404WhenPlateNotFound`.
|
||||
- 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
404
README.md
|
|
@ -11,7 +11,7 @@ The user enters a registration number, composes a letter (from a template or fre
|
|||
| Layer | Technology |
|
||||
|-------------|-----------------------------------------|
|
||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia |
|
||||
| Backend | Java 21, Spring Boot 3 |
|
||||
| Backend | Java 21, Spring Boot 4, Gradle |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Auth | Spring Security + JWT |
|
||||
| Payments | Stripe (cards + Swish) |
|
||||
|
|
@ -34,13 +34,53 @@ The user enters a registration number, composes a letter (from a template or fre
|
|||
git clone <repo-url> bilhej
|
||||
cd bilhej
|
||||
cp .env.example .env # fill in your keys
|
||||
docker compose up -d
|
||||
docker compose up -d # or: ./gradlew up
|
||||
```
|
||||
|
||||
The app will be available at:
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend API: `http://localhost:8080`
|
||||
- PostgreSQL: `localhost:5432`
|
||||
- Mailpit (dev SMTP inbox): `http://localhost:8025`
|
||||
|
||||
### Architecture inside Docker Compose
|
||||
|
||||
```
|
||||
Browser Docker Compose network
|
||||
─────── ─────────────────────
|
||||
│ ┌──────────────────┐
|
||||
│ http://localhost:3000 │ frontend (Vite) │
|
||||
├────────────────────────→│ :3000 │
|
||||
│ │ proxy: /api → │
|
||||
│ GET /api/orders │ backend:8080 │
|
||||
│ └────────┬─────────┘
|
||||
│ │
|
||||
│ ┌────────▼─────────┐
|
||||
│ │ backend (Spring) │
|
||||
│ │ :8080 │
|
||||
│ │ profile: docker │
|
||||
│ └────────┬─────────┘
|
||||
│ │
|
||||
│ ┌────────▼─────────┐
|
||||
│ │ postgres (16) │
|
||||
│ │ :5432 │
|
||||
│ └──────────────────┘
|
||||
│ ┌──────────────────┐
|
||||
│ │ mailpit │
|
||||
│ │ SMTP :1025 │
|
||||
│ │ UI :8025 │
|
||||
│ └──────────────────┘
|
||||
```
|
||||
|
||||
**Vite proxy:** The Vite dev server proxies `/api/*` requests to the backend container.
|
||||
No CORS configuration needed in development — the browser never calls the backend directly.
|
||||
|
||||
**Spring profiles:**
|
||||
| Profile | Datasource | Use |
|
||||
|---------|-----------|-----|
|
||||
| default | H2 in-memory | Local IDE dev (`./gradlew :backend:bootRun`) |
|
||||
| `docker` | PostgreSQL + dev Flyway seeds | Docker Compose dev / CI |
|
||||
| `docker,prod` | PostgreSQL, schema only, admin bootstrap | Deploy (`docker-compose.prod.yml`) |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -57,6 +97,147 @@ Copy `.env.example` to `.env` and fill in:
|
|||
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||
| `SWISH_NUMBER` | Swish number for payment instructions |
|
||||
| `APP_PUBLIC_BASE_URL` | Base URL for password-reset links (dev: `http://localhost:3000`) |
|
||||
| `MAIL_HOST` | SMTP host (Docker dev uses `mailpit` automatically; leave empty to log links only) |
|
||||
| `MAIL_PORT` | SMTP port (`1025` for Mailpit, `587` for most providers) |
|
||||
| `MAIL_USERNAME` | SMTP username (empty for Mailpit) |
|
||||
| `MAIL_PASSWORD` | SMTP password (empty for Mailpit) |
|
||||
| `MAIL_FROM` | From address (e.g. `noreply@bilhej.se`) |
|
||||
| `ADMIN_EMAIL` | Production admin login (e.g. `admin@bilhej.se`) |
|
||||
| `ADMIN_PASSWORD` | Strong production admin password (not `test1234`) |
|
||||
|
||||
**Dev-only accounts** (from `db/dev-migration`, not used in production):
|
||||
|
||||
| Email | Password | Role |
|
||||
|-------|----------|------|
|
||||
| `test@bilhej.se` | `test1234` | user (e2e / local) |
|
||||
| `admin@bilhalsning.se` | `test1234` | admin (local docker only) |
|
||||
|
||||
---
|
||||
|
||||
## Database access
|
||||
|
||||
PostgreSQL runs in Docker. Any GUI client works (IntelliJ IDEA, DBeaver, TablePlus,
|
||||
pgAdmin) — same idea as a normal remote database.
|
||||
|
||||
### Local dev (`docker compose up`)
|
||||
|
||||
| Setting | Value |
|
||||
|---------|--------|
|
||||
| Host | `localhost` |
|
||||
| Port | `5432` |
|
||||
| Database | from `.env` → `POSTGRES_DB` (default `bilhej`) |
|
||||
| User / password | `POSTGRES_USER` / `POSTGRES_PASSWORD` |
|
||||
|
||||
**IntelliJ IDEA:** Database tool window → `+` → Data Source → PostgreSQL → fill in above →
|
||||
Test Connection → OK.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
docker exec -it bilhej-postgres psql -U bilhej -d bilhej
|
||||
```
|
||||
|
||||
### Production (server)
|
||||
|
||||
Postgres is bound to **localhost only** on the server (`127.0.0.1:5433`) so it is not
|
||||
exposed to the internet. Use an **SSH tunnel** from your laptop, then point IntelliJ (or
|
||||
DBeaver) at `localhost`.
|
||||
|
||||
1. On the server, recreate the stack once so the port mapping is active (after deploy).
|
||||
|
||||
2. From your laptop:
|
||||
|
||||
```bash
|
||||
ssh -N -L 5433:127.0.0.1:5433 you@srvr.nu
|
||||
```
|
||||
|
||||
3. In IntelliJ / DBeaver:
|
||||
|
||||
| Setting | Value |
|
||||
|---------|--------|
|
||||
| Host | `localhost` |
|
||||
| Port | `5433` |
|
||||
| Database | prod `POSTGRES_DB` |
|
||||
| User / password | prod secrets |
|
||||
|
||||
**CLI on the server** (no GUI):
|
||||
|
||||
```bash
|
||||
docker exec -it bilhej-postgres-prod psql -U bilhej -d bilhej
|
||||
```
|
||||
|
||||
### Manual prod cleanup (keep data, remove dev seeds)
|
||||
|
||||
```sql
|
||||
DELETE FROM orders
|
||||
WHERE user_id = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
|
||||
|
||||
DELETE FROM users
|
||||
WHERE email IN ('test@bilhalsning.se', 'test@bilhej.se', 'admin@bilhalsning.se');
|
||||
```
|
||||
|
||||
Then deploy with `ADMIN_EMAIL` / `ADMIN_PASSWORD` set — the app creates the production
|
||||
admin on startup. No need to insert a password hash by hand.
|
||||
|
||||
### Email (password reset)
|
||||
|
||||
The app only needs to **send** mail (forgot-password). You do not need Office 365 or a mailbox on
|
||||
the server unless you want human addresses like `support@bilhej.se`.
|
||||
|
||||
**Local Docker (Mailpit):** `docker compose up` starts [Mailpit](https://mailpit.axllent.org/)
|
||||
(`ghcr.io/axllent/mailpit:v1.28`). All outbound mail is captured—nothing is sent to the internet.
|
||||
|
||||
If `docker compose pull` fails on Docker Hub, pull explicitly:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/axllent/mailpit:v1.28
|
||||
```
|
||||
|
||||
1. Open **http://localhost:8025**
|
||||
2. Use **Glömt lösenord?** on the login page (or **Byt lösenord** in the header when logged in)
|
||||
3. Open the message in Mailpit and click the reset link
|
||||
|
||||
To disable Mailpit and log links only, remove `MAIL_HOST` from the backend service in
|
||||
`docker-compose.yml` or set `MAIL_HOST=` in `.env`.
|
||||
|
||||
**Production:** [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'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -75,11 +256,11 @@ bilhej/
|
|||
│ │ ├── api/ # API client and endpoints
|
||||
│ │ ├── assets/ # Static assets, CSS
|
||||
│ │ ├── App.vue
|
||||
│ │ └── main.js
|
||||
│ │ └── main.ts
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.js
|
||||
│ ├── vite.config.ts
|
||||
│ └── package.json
|
||||
├── backend/ # Spring Boot 3
|
||||
├── backend/ # Spring Boot 4
|
||||
│ ├── src/main/java/se/bilhalsning/
|
||||
│ │ ├── BilHejApplication.java
|
||||
│ │ ├── config/ # Security, CORS, Stripe config
|
||||
|
|
@ -90,36 +271,222 @@ bilhej/
|
|||
│ │ ├── service/ # Business logic
|
||||
│ │ └── security/ # JWT filter, user details
|
||||
│ └── src/main/resources/
|
||||
│ ├── application.yml
|
||||
│ ├── application.yml # default profile (H2)
|
||||
│ ├── application-docker.yml # docker profile (PostgreSQL)
|
||||
│ └── db/migration/ # Flyway migrations
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.yml # dev: postgres + backend (bootRun) + frontend (Vite HMR)
|
||||
├── docker-compose.prod.yml # prod: multi-stage builds, no source mounts, restart: unless-stopped
|
||||
├── docker/
|
||||
│ ├── backend.Dockerfile
|
||||
│ └── frontend.Dockerfile
|
||||
│ ├── backend.Dockerfile # dev: JDK + gradle :backend:bootRun
|
||||
│ ├── backend.prod.Dockerfile # prod: multi-stage (Gradle → JRE Alpine, non-root)
|
||||
│ ├── frontend.Dockerfile # dev: Node + vite dev server
|
||||
│ ├── frontend.prod.Dockerfile # prod: multi-stage (Node → nginx)
|
||||
│ ├── nginx.conf # prod: SPA fallback + /api proxy
|
||||
│ └── entrypoint.sh # prod: self-signed cert generation
|
||||
├── gradlew # Gradle wrapper (run from repo root)
|
||||
├── gradle/
|
||||
│ └── wrapper/
|
||||
├── settings.gradle # rootProject.name + include 'backend'
|
||||
├── build.gradle # convenience tasks: check, up, down, reset
|
||||
├── .env.example
|
||||
├── README.md
|
||||
├── REQUIREMENTS.md
|
||||
├── CODING_GUIDELINES.md
|
||||
└── ARCHITECTURE.md
|
||||
└── CODING_GUIDELINES.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development vs Production
|
||||
|
||||
| Aspect | `docker compose up -d` | `docker compose -f docker-compose.prod.yml up -d` |
|
||||
|--------|------------------------|---------------------------------------------------|
|
||||
| Backend | `./gradlew :backend:bootRun` (compiles on change) | Multi-stage build → `java -jar app.jar` |
|
||||
| Backend image | `eclipse-temurin:21-jdk` (~400 MB) | `eclipse-temurin:21-jre-alpine` (~200 MB) |
|
||||
| Backend user | root | `bilhej` (non-root) |
|
||||
| Frontend | Vite dev server (HMR, `--host 0.0.0.0`) | nginx serving static `dist/` |
|
||||
| API proxy | Vite built-in proxy (`/api` → `backend:8080`) | nginx `proxy_pass` |
|
||||
| SSL | None | Self-signed cert auto-generated on first start (`.certs/` volume) |
|
||||
| Source mounts | Yes (live edit) | No (files baked into image) |
|
||||
| Restart policy | Manual | `unless-stopped` |
|
||||
| Purpose | Write code, instant feedback | Verify production build |
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Deployments are fully automated via Forgejo Actions. The pipeline builds production Docker images and starts them on the server.
|
||||
|
||||
### One-time Setup
|
||||
|
||||
Before the first deploy, complete these steps on the production server (`srvr.nu`):
|
||||
|
||||
1. **Add Forgejo Actions Secrets**
|
||||
|
||||
Go to **Forgejo → Repository Settings → Actions → Secrets** and add:
|
||||
|
||||
| Secret | Description |
|
||||
|--------|-------------|
|
||||
| `POSTGRES_DB` | Database name (e.g., `bilhej`) |
|
||||
| `POSTGRES_USER` | Database user |
|
||||
| `POSTGRES_PASSWORD` | Strong database password |
|
||||
| `JWT_SECRET` | `openssl rand -hex 32` |
|
||||
| `STRIPE_SECRET_KEY` | Stripe secret key |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret |
|
||||
| `STRIPE_PRICE_ID` | Stripe price ID for single letter |
|
||||
| `SWISH_NUMBER` | Swish phone number for payment instructions |
|
||||
| `ADMIN_EMAIL` | Production admin email (e.g. `admin@bilhej.se`) |
|
||||
| `ADMIN_PASSWORD` | Strong unique admin password (password manager) |
|
||||
| `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
|
||||
|
||||
### All-in-one (from repo root)
|
||||
|
||||
```bash
|
||||
./gradlew check # lint → frontend test → backend test → integration test
|
||||
./gradlew up # docker compose up -d
|
||||
./gradlew down # docker compose down
|
||||
./gradlew reset # docker compose down -v && docker compose up -d (full DB reset)
|
||||
```
|
||||
|
||||
### Frontend (dev server with HMR)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
npm install # first time only
|
||||
npm run dev # :3000 with HMR
|
||||
```
|
||||
|
||||
### Backend (IDE or CLI)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
./mvnw spring-boot:run
|
||||
./gradlew :backend:bootRun # :8080, profile: default (H2)
|
||||
```
|
||||
|
||||
### Stripe Webhooks (local testing)
|
||||
|
|
@ -128,10 +495,15 @@ cd backend
|
|||
stripe listen --forward-to localhost:8080/api/webhooks/stripe
|
||||
```
|
||||
|
||||
### Database reset
|
||||
|
||||
```bash
|
||||
./gradlew reset # wipes DB volume and restarts containers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [REQUIREMENTS.md](./REQUIREMENTS.md) — Full product requirements and business model
|
||||
- [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) — Code conventions and standards
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Detailed architecture and data flow
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ the user assumes full responsibility for content.
|
|||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | Vue.js 3 (Composition API), Vite, Pinia state management, Vue Router |
|
||||
| Backend API | Java 21, Spring Boot 3, Spring Security (JWT), Spring Data JPA |
|
||||
| Backend API | Java 21, Spring Boot 4, Spring Security (JWT), Spring Data JPA |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Deployment | Docker, Docker Compose |
|
||||
| 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
|
||||
┌──────────────────▼───────────────────────┐
|
||||
│ Spring Boot 3 (Java 21) │
|
||||
│ Spring Boot 4 (Java 21) │
|
||||
│ Port: 8080 │
|
||||
│ ┌────────────┐ ┌────────────────────┐ │
|
||||
│ │ 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 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. |
|
||||
| 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"_ |
|
||||
| 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"_ |
|
||||
|
||||
### 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
|
||||
Backend: Java 21, Spring Boot 3, Spring Security (JWT), JPA/Hibernate
|
||||
Backend: Java 21, Spring Boot 4, Spring Security (JWT), JPA/Hibernate
|
||||
Database: PostgreSQL 16
|
||||
Deploy: Docker, Docker Compose, Nginx reverse proxy
|
||||
Hosting: Home server (Phase 0) → Swedish VPS (Phase 1)
|
||||
|
|
|
|||
3
backend/.gitattributes
vendored
Normal file
3
backend/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/gradlew text eol=lf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
37
backend/.gitignore
vendored
Normal file
37
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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/
|
||||
100
backend/build.gradle
Normal file
100
backend/build.gradle
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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
Normal file
93
backend/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
@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
|
||||
13
backend/src/main/java/se/bilhalsning/BilHejApplication.java
Normal file
13
backend/src/main/java/se/bilhalsning/BilHejApplication.java
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record AuthResponse(String token) {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ChangeEmailRequest(
|
||||
@NotBlank @Email String newEmail, @NotBlank String password) {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
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) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ConfirmEmailChangeRequest(@NotBlank String token, @NotBlank String password) {}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record ErrorResponse(String message) {}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ForgotPasswordRequest(@NotBlank @Email String email) {}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record MessageResponse(String message) {}
|
||||
15
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
15
backend/src/main/java/se/bilhalsning/dto/OrderResponse.java
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record UpdateAdminNotesRequest(
|
||||
String adminNotes
|
||||
) {}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.dto;
|
||||
|
||||
public record VehicleInfoResponse(
|
||||
String make,
|
||||
String model,
|
||||
int year,
|
||||
String color,
|
||||
String fuel
|
||||
) {}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
162
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
162
backend/src/main/java/se/bilhalsning/entity/Order.java
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
21
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
21
backend/src/main/java/se/bilhalsning/entity/OrderStatus.java
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
112
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
112
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class EmailAlreadyExistsException extends RuntimeException {
|
||||
|
||||
public EmailAlreadyExistsException(String email) {
|
||||
super("Email already registered: " + email);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class EmailChangeTokenInvalidException extends RuntimeException {
|
||||
|
||||
public EmailChangeTokenInvalidException() {
|
||||
super("Bekräftelselänken är ogiltig eller har gått ut.");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidCredentialsException extends RuntimeException {
|
||||
public InvalidCredentialsException() {
|
||||
super("Felaktig e-post eller lösenord");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidOrderStateException extends RuntimeException {
|
||||
public InvalidOrderStateException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class OrderNotFoundException extends RuntimeException {
|
||||
public OrderNotFoundException(UUID id) {
|
||||
super("Order not found: " + id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class PasswordResetTokenInvalidException extends RuntimeException {
|
||||
|
||||
public PasswordResetTokenInvalidException() {
|
||||
super("Återställningslänken är ogiltig eller har gått ut");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleLookupException extends RuntimeException {
|
||||
public VehicleLookupException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class VehicleNotFoundException extends RuntimeException {
|
||||
public VehicleNotFoundException(String plate) {
|
||||
super("Vehicle not found: " + plate);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
163
backend/src/main/java/se/bilhalsning/service/EmailService.java
Normal file
163
backend/src/main/java/se/bilhalsning/service/EmailService.java
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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 på 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
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";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
37
backend/src/main/resources/application-docker.yml
Normal file
37
backend/src/main/resources/application-docker.yml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
21
backend/src/main/resources/application-prod.yml
Normal file
21
backend/src/main/resources/application-prod.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
|
||||
47
backend/src/main/resources/application.yml
Normal file
47
backend/src/main/resources/application.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- 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'
|
||||
);
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
-- 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'
|
||||
);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- 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');
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-- 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'
|
||||
);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
ALTER TABLE orders ADD COLUMN admin_notes TEXT;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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'))
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'user';
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
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'
|
||||
)
|
||||
);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package se.bilhalsning;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class BilHejApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
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[] {})));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
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
Loading…
Reference in a new issue