64 Commits

Author SHA1 Message Date
b39fa0b693 Merge branch 'dev' into prova
All checks were successful
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-03 13:41:38 +01:00
printcalc-ci
f3ea2be8b0 style: apply prettier formatting 2026-03-03 12:41:15 +00:00
c66903d22e fix(deploy): new test
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-03 13:39:50 +01:00
4bc815b04d Merge pull request 'fix(deploy): prova workflow' (#12) from prova into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 42s
Build and Deploy / deploy (push) Successful in 10s
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 24s
Reviewed-on: #12
2026-03-03 13:24:02 +01:00
127a321621 fix(deploy): fix security
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 25s
2026-03-03 13:22:45 +01:00
27af5f7ebb fix(deploy): prova workflow
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Failing after 27s
PR Checks / test-backend (pull_request) Successful in 38s
2026-03-03 13:02:24 +01:00
edef17d0ad Merge remote-tracking branch 'origin/prova' into prova
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Failing after 21s
PR Checks / test-backend (pull_request) Successful in 37s
# Conflicts:
#	.gitea/workflows/pr-checks.yaml
2026-03-03 13:00:28 +01:00
76f648e82a fix(deploy): prova workflow 2026-03-03 13:00:14 +01:00
printcalc-ci
b68d702a3d style: apply prettier formatting 2026-03-03 11:58:34 +00:00
173a6b70d2 fix(deploy): prova workflow
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Failing after 29s
PR Checks / test-backend (pull_request) Successful in 57s
2026-03-03 12:58:21 +01:00
c08add9b1e Merge remote-tracking branch 'origin/prova' into prova
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / qodana (pull_request) Failing after 8s
PR Checks / test-backend (pull_request) Successful in 39s
2026-03-03 12:55:20 +01:00
3ea8c1b2ad fix(deploy): prova workflow 2026-03-03 12:55:09 +01:00
printcalc-ci
20293cc044 style: apply prettier formatting 2026-03-03 11:46:26 +00:00
dd6f723271 fix(deploy): prova workflow
Some checks failed
PR Checks / qodana (pull_request) Failing after 32s
PR Checks / prettier-autofix (pull_request) Successful in 39s
PR Checks / test-backend (pull_request) Successful in 38s
2026-03-03 12:45:23 +01:00
c28d22ccdb fix(deploy): new worlfkow gitea
All checks were successful
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
2026-03-03 12:43:20 +01:00
3abe90d8f3 fix(deploy): new worlfkow gitea 2026-03-03 12:42:23 +01:00
04cbf00a2d Merge branch 'main' into dev
All checks were successful
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 18s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / deploy (push) Successful in 10s
Build, Test, Deploy and Analysis / qodana (push) Has been skipped
# Conflicts:
#	.gitea/workflows/cicd.yaml
2026-03-03 12:35:11 +01:00
be8e523574 fix(deploy): cicd.yaml
Some checks failed
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 12s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / test-backend (pull_request) Successful in 39s
Build, Test, Deploy and Analysis / qodana (push) Has been skipped
Build, Test, Deploy and Analysis / test-backend (push) Has been skipped
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 17s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 12:33:18 +01:00
c680486157 fix(front-end): quantity changes and normalization
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 12s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 25s
Build, Test, Deploy and Analysis / deploy (push) Successful in 10s
2026-03-03 12:05:00 +01:00
90bdb5384d fix(front-end): color selector
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 17s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 41s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 23s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 11:49:40 +01:00
04ecda99ee fix(chore):
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 13s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 34s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 11:16:40 +01:00
2d1a783e9c fix(chore): prepare Qodana dirs
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 56s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 51s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 17s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 10:30:43 +01:00
ef2be1a3be Merge pull request 'dev' (#10) from dev into main
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 39s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 15s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
Reviewed-on: #10
2026-03-03 10:26:24 +01:00
5c27d4d16b feat(back-end): pla tought
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 41s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 10:24:21 +01:00
654aa775db fix(back-end): implementation of security better
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 12s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 37s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 41s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 10:11:12 +01:00
9307ba6fba Merge pull request 'produzione 1' (#9) from dev into main
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 14s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / deploy (push) Successful in 7s
Reviewed-on: #9
2026-03-03 09:58:03 +01:00
c0d6b480c1 fix(back-end):
Some checks failed
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 38s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 16s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
2026-03-03 09:57:39 +01:00
fdb1bcb282 fix(back-end): noozle diameter
Some checks failed
Build, Test, Deploy and Analysis / test-backend (push) Successful in 54s
Build, Test, Deploy and Analysis / qodana (push) Failing after 1m12s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 37s
Build, Test, Deploy and Analysis / deploy (push) Successful in 9s
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 11s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been cancelled
Build, Test, Deploy and Analysis / deploy (pull_request) Has been cancelled
Build, Test, Deploy and Analysis / test-backend (pull_request) Has been cancelled
2026-03-03 09:44:35 +01:00
09a4ac064f fix(back-end) security issue
Some checks failed
Build, Test, Deploy and Analysis / test-backend (pull_request) Successful in 39s
Build, Test, Deploy and Analysis / build-and-push (push) Failing after 34s
Build, Test, Deploy and Analysis / deploy (pull_request) Successful in 8s
Build, Test, Deploy and Analysis / qodana (push) Failing after 8s
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 11s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 40s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Successful in 20s
Build, Test, Deploy and Analysis / deploy (push) Has been skipped
2026-03-03 09:30:22 +01:00
c00ca5a32e feat(chore): added qodana analysis job
Some checks failed
Build, Test, Deploy and Analysis / test-backend (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / qodana (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / qodana (push) Failing after 32s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 1m30s
Build, Test, Deploy and Analysis / build-and-push (push) Successful in 42s
Build, Test, Deploy and Analysis / deploy (push) Successful in 8s
2026-03-03 09:19:05 +01:00
9955f23f31 feat(chore): added qodana analysis job
Some checks failed
Build, Test, Deploy and Analysis / qodana (pull_request) Waiting to run
Build, Test, Deploy and Analysis / qodana (push) Failing after 5m7s
Build, Test, Deploy and Analysis / test-backend (push) Successful in 40s
Build, Test, Deploy and Analysis / test-backend (pull_request) Failing after 0s
Build, Test, Deploy and Analysis / build-and-push (pull_request) Has been skipped
Build, Test, Deploy and Analysis / deploy (pull_request) Has been skipped
Build, Test, Deploy and Analysis / build-and-push (push) Failing after 0s
Build, Test, Deploy and Analysis / deploy (push) Has been skipped
2026-03-03 09:04:12 +01:00
25afb355b4 feat(front-end): make responsive back-office 2026-03-03 08:43:13 +01:00
b7c399e3cb feat(back-end): new stock db and back-office improvements 2026-03-02 20:19:19 +01:00
02e58ea00f fix(home): reordered calculator and capabilities sections
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 56s
Build, Test and Deploy / build-and-push (push) Successful in 25s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-03-02 19:44:55 +01:00
819e00e067 feat(back-end): fix order page
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 47s
Build, Test and Deploy / build-and-push (push) Successful in 25s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-27 20:22:18 +01:00
ed76b13e4c feat(back-end and front-end): back-office pazzo
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 44s
Build, Test and Deploy / build-and-push (push) Successful in 46s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 15:46:41 +01:00
47553ebb82 feat(back-end and front-end): back-office test 2026-02-27 15:07:39 +01:00
949770a741 feat(back-end and front-end): back-office 2026-02-27 15:07:32 +01:00
65e1ee3be6 feat(back-end and front-end): back-office
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 1m4s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 12:51:48 +01:00
3f938db257 feat(back-end and front-end): back-office
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 38s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-27 12:44:06 +01:00
1598f35c08 feat(back-end): email improvements
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 34s
Build, Test and Deploy / deploy (push) Has been skipped
Build, Test and Deploy / build-and-push (push) Has been skipped
2026-02-27 12:09:50 +01:00
8e9afbf260 feat(back-end): email improvements
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Has been cancelled
2026-02-27 12:09:37 +01:00
6e52988cdd feat(front-end): delay on update
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 36s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-27 11:50:17 +01:00
2e701d5597 feat(front-end): traslation de and fr4
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 11:37:34 +01:00
150faf3b5a feat(front-end): traslation de and fr
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-27 11:32:14 +01:00
64cd90eabc feat(front-end): traslation en 2026-02-27 11:10:45 +01:00
877171ceb1 feat(front-end): traslation in italian 2026-02-27 11:05:20 +01:00
521009de7c feat(front-end): gestione quantità massima e price for piece 2026-02-27 10:59:02 +01:00
8028e9c7b7 feat(back-end): model dimension 2026-02-27 10:43:59 +01:00
a85c57032d fix(front-end): calculator improvements 2026-02-27 10:43:06 +01:00
219b4e127d fix(front-end): calculator improvements in base quality settings
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 11s
2026-02-27 10:22:32 +01:00
f7ddab0e93 fix(front-end): red write and last commit for today
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-26 22:30:34 +01:00
80a41b0cc2 feat(frontend e back-end): termini e condizioni
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 38s
Build, Test and Deploy / build-and-push (push) Successful in 1m3s
Build, Test and Deploy / deploy (push) Successful in 11s
2026-02-26 22:24:51 +01:00
6f3e601f21 feat(frontend): about improvments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-26 21:42:12 +01:00
e82862821e feat(frontend): fav icon e upload multiple files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-26 19:41:40 +01:00
b6230e69e4 feat(frontend): update images, and desing home
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 42s
Build, Test and Deploy / build-and-push (push) Successful in 42s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-26 19:09:59 +01:00
c58d674a70 feat(back-end - front end): improvements in email and invoice rendering
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 45s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-25 16:35:58 +01:00
fecb394272 fix(back-end) calculator improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 41s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-25 15:05:23 +01:00
54d12f4da0 fix(front-end): improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 41s
Build, Test and Deploy / build-and-push (push) Successful in 49s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-25 10:53:08 +01:00
4ddd33662d fix(front-end): 3d view only for stl
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-24 13:12:29 +01:00
a6eae757c5 feat(front-end): upload only supported file and add step warning message
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 40s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-24 13:06:16 +01:00
6463fac211 fix(back-end): email fix
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 40s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-24 10:47:13 +01:00
57f6301e03 feat(back-end front-end): integration with twint
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 43s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-24 10:07:04 +01:00
699a968875 feat(back-end front-end): upgrade to the order componen instead of payment and order-confirmed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 39s
Build, Test and Deploy / build-and-push (push) Successful in 1m3s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-24 08:44:42 +01:00
243 changed files with 19162 additions and 4107 deletions

View File

@@ -1,11 +1,11 @@
name: Build, Test and Deploy
name: Build and Deploy
on:
push:
branches: [main, int, dev]
concurrency:
group: print-calculator-${{ gitea.ref }}
group: print-calculator-deploy-${{ gitea.ref }}
cancel-in-progress: true
jobs:
@@ -18,8 +18,9 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
java-version: "21"
distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle
run: |
@@ -27,8 +28,42 @@ jobs:
chmod +x gradlew
./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push:
needs: test-backend
needs: [test-backend, test-frontend]
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -106,21 +141,15 @@ jobs:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# 1) Prende il secret base64 e rimuove spazi/newline/CR
printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64
# 2) (debug sicuro) stampa solo la lunghezza della base64
echo "b64_len=$(wc -c < /tmp/key.b64)"
# 3) Decodifica in chiave privata
base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519
# 4) Rimuove eventuali CRLF dentro la chiave (se proviene da Windows)
tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean
mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
@@ -128,7 +157,6 @@ jobs:
- name: Write env and compose to server
shell: bash
run: |
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
DEPLOY_TAG="prod"
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
@@ -138,10 +166,8 @@ jobs:
fi
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
# 2. Start with the static env file content
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 3. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -156,32 +182,28 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi
# 4. Append DB and Docker credentials (quoted)
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
# 5. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:"
grep -v "PASSWORD" /tmp/full_env.env || true
ADMIN_TTL="${{ secrets.ADMIN_SESSION_TTL_MINUTES }}"
ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
# 5. Send env to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env
# 6. Send docker-compose.deploy.yml to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
- name: Trigger deploy on Unraid (forced command key)
shell: bash
run: |
set -euo pipefail
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
# e assicuriamoci che l'input sia pulito
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"

View File

@@ -0,0 +1,172 @@
name: PR Checks
on:
pull_request:
branches: [main, int, dev]
concurrency:
group: print-calculator-pr-${{ gitea.ref }}
cancel-in-progress: true
jobs:
prettier-autofix:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Apply formatting with Prettier
shell: bash
run: |
npx --yes prettier@3.6.2 --write \
"frontend/src/**/*.{ts,html,scss,css,json}" \
".gitea/workflows/*.{yml,yaml}"
- name: Commit and push formatting changes
shell: bash
run: |
if git diff --quiet; then
echo "No formatting changes to commit."
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends jq
fi
EVENT_FILE="${GITHUB_EVENT_PATH:-}"
if [[ -z "$EVENT_FILE" || ! -f "$EVENT_FILE" ]]; then
echo "Event payload not found, skipping auto-push."
exit 0
fi
HEAD_REPO="$(jq -r '.pull_request.head.repo.full_name // empty' "$EVENT_FILE")"
BASE_REPO="$(jq -r '.repository.full_name // empty' "$EVENT_FILE")"
PR_BRANCH="$(jq -r '.pull_request.head.ref // empty' "$EVENT_FILE")"
if [[ -z "$PR_BRANCH" ]]; then
echo "PR branch not found in event payload, skipping auto-push."
exit 0
fi
if [[ -n "$HEAD_REPO" && -n "$BASE_REPO" && "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "PR from fork ($HEAD_REPO), skipping auto-push."
exit 0
fi
git config user.name "printcalc-ci"
git config user.email "ci@printcalculator.local"
git add frontend/src .gitea/workflows
git commit -m "style: apply prettier formatting"
git push origin "HEAD:${PR_BRANCH}"
security-sast:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Python and Semgrep
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip
python3 -m pip install --upgrade pip
python3 -m pip install semgrep
- name: Run Semgrep (SAST)
shell: bash
run: |
semgrep --version
semgrep --config auto --error \
--exclude frontend/node_modules \
--exclude backend/build \
backend/src frontend/src
- name: Install Gitleaks
shell: bash
run: |
set -euo pipefail
VERSION="8.24.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Run Gitleaks (secrets scan)
shell: bash
run: |
set +e
gitleaks detect --source . --no-git --redact --exit-code 1 \
--report-format json --report-path /tmp/gitleaks-report.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Gitleaks findings:"
cat /tmp/gitleaks-report.json
fi
exit $rc
test-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle
run: |
cd backend
chmod +x gradlew
./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

3
.gitignore vendored
View File

@@ -46,3 +46,6 @@ build/
./storage_quotes
storage_orders
storage_quotes
# Qodana local reports/artifacts
backend/.qodana/

View File

@@ -24,11 +24,15 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'xyz.capybara:clamav-client:2.1.2'
runtimeOnly 'org.postgresql:postgresql'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok'

View File

@@ -5,8 +5,6 @@ echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---"
env
echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection

48
backend/qodana.yaml Normal file
View File

@@ -0,0 +1,48 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: "21" #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-jvm:2025.3

View File

@@ -2,12 +2,12 @@ package com.printcalculator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class})
@EnableTransactionManagement
@EnableScheduling
@EnableAsync

View File

@@ -0,0 +1,47 @@
package com.printcalculator.config;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").denyAll()
.requestMatchers("/api/admin/auth/login").permitAll()
.requestMatchers("/api/admin/**").authenticated()
.anyRequest().permitAll()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
}))
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -1,50 +1,102 @@
package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.time.Year;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}")
private boolean contactRequestAdminMailEnabled;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
// TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests";
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private static final Set<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of(
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-7z-compressed",
"application/gzip",
"application/x-gzip",
"application/x-tar",
"application/x-bzip2",
"application/x-xz",
"application/zstd",
"application/x-zstd"
);
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) {
ClamAVService clamAVService,
EmailNotificationService emailNotificationService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService;
this.emailNotificationService = emailNotificationService;
}
// 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto,
@Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Accettazione Termini e Privacy obbligatoria."
);
}
// 1. Create Request
CustomQuoteRequest request = new CustomQuoteRequest();
@@ -63,6 +115,7 @@ public class CustomQuoteRequestController {
request = requestRepo.save(request);
// 2. Handle Attachments
int attachmentsCount = 0;
if (files != null && !files.isEmpty()) {
if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed.");
@@ -71,6 +124,13 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
if (isCompressedFile(file)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Compressed files are not allowed."
);
}
// Scan for virus
clamAVService.scan(file.getInputStream());
@@ -83,8 +143,7 @@ public class CustomQuoteRequestController {
// Generate path
UUID fileUuid = UUID.randomUUID();
String ext = getExtension(file.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext;
String storedFilename = fileUuid + ".upload";
// Note: We don't have attachment ID yet.
// We'll save attachment first to get ID.
@@ -93,17 +152,28 @@ public class CustomQuoteRequestController {
attachment = attachmentRepo.save(attachment);
String relativePath = "quote-requests/" + request.getId() + "/attachments/" + attachment.getId() + "/" + storedFilename;
attachment.setStoredRelativePath(relativePath);
Path relativePath = Path.of(
"quote-requests",
request.getId().toString(),
"attachments",
attachment.getId().toString(),
storedFilename
);
attachment.setStoredRelativePath(relativePath.toString());
attachmentRepo.save(attachment);
// Save file to disk
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
Path absolutePath = resolveWithinStorageRoot(relativePath);
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
}
attachmentsCount++;
}
}
sendAdminContactRequestNotification(request, attachmentsCount);
return ResponseEntity.ok(request);
}
@@ -118,10 +188,80 @@ public class CustomQuoteRequestController {
// Helper
private String getExtension(String filename) {
if (filename == null) return "dat";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "dat";
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
}
return "dat";
}
private boolean isCompressedFile(MultipartFile file) {
String ext = getExtension(file.getOriginalFilename());
if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) {
return true;
}
String mime = file.getContentType();
return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase());
}
private Path resolveWithinStorageRoot(Path relativePath) {
try {
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize();
if (!absolutePath.startsWith(STORAGE_ROOT)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
return absolutePath;
} catch (InvalidPathException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
}
}
private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
if (!contactRequestAdminMailEnabled) {
return;
}
if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
logger.warn("Contact request admin notification enabled but no admin address configured.");
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put("createdAt", request.getCreatedAt());
templateData.put("requestType", safeValue(request.getRequestType()));
templateData.put("customerType", safeValue(request.getCustomerType()));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail(
contactRequestAdminMailAddress,
"Nuova richiesta di contatto #" + request.getId(),
"contact-request-admin",
templateData
);
}
private String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
}

View File

@@ -1,47 +0,0 @@
package com.printcalculator.controller;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/dev/email")
@Profile("local")
public class DevEmailTestController {
private final TemplateEngine templateEngine;
public DevEmailTestController(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
@GetMapping("/test-template")
public ResponseEntity<String> testTemplate() {
Context context = new Context();
Map<String, Object> templateData = new HashMap<>();
UUID orderId = UUID.randomUUID();
templateData.put("customerName", "Mario Rossi");
templateData.put("orderId", orderId);
templateData.put("orderNumber", orderId.toString().split("-")[0]);
templateData.put("orderDetailsUrl", "https://tuosito.it/ordine/" + orderId);
templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", "45.50");
context.setVariables(templateData);
String html = templateEngine.process("email/order-confirmation", context);
return ResponseEntity.ok()
.header("Content-Type", "text/html; charset=utf-8")
.body(html);
}
}

View File

@@ -3,18 +3,28 @@ package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.*; // This line replaces specific entity imports
import com.printcalculator.entity.LayerHeightOption;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.NozzleOption;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.NozzleOptionRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.OrcaProfileResolver;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@@ -24,89 +34,177 @@ public class OptionsController {
private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo;
private final PrinterMachineRepository printerMachineRepo;
private final MaterialOrcaProfileMapRepository materialOrcaMapRepo;
private final OrcaProfileResolver orcaProfileResolver;
public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo) {
NozzleOptionRepository nozzleRepo,
PrinterMachineRepository printerMachineRepo,
MaterialOrcaProfileMapRepository materialOrcaMapRepo,
OrcaProfileResolver orcaProfileResolver) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo;
this.printerMachineRepo = printerMachineRepo;
this.materialOrcaMapRepo = materialOrcaMapRepo;
this.orcaProfileResolver = orcaProfileResolver;
}
@GetMapping("/api/calculator/options")
public ResponseEntity<OptionsResponse> getOptions() {
// 1. Materials & Variants
@Transactional(readOnly = true)
public ResponseEntity<OptionsResponse> getOptions(
@RequestParam(value = "printerMachineId", required = false) Long printerMachineId,
@RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter
) {
List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findByIsActiveTrue().stream()
.sorted(Comparator
.comparing((FilamentVariant v) -> safeMaterialCode(v.getFilamentMaterialType()), String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> safeString(v.getVariantDisplayName()), String.CASE_INSENSITIVE_ORDER))
.toList();
Set<Long> compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter);
List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER))
.map(type -> {
if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) {
return null;
}
List<OptionsResponse.VariantOption> variants = allVariants.stream()
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
.filter(v -> v.getFilamentMaterialType() != null
&& v.getFilamentMaterialType().getId().equals(type.getId()))
.map(v -> new OptionsResponse.VariantOption(
v.getId(),
v.getVariantDisplayName(),
v.getColorName(),
getColorHex(v.getColorName()), // Need helper or store hex in DB
v.getStockSpools().doubleValue() <= 0
resolveHexColor(v),
v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
toStockFilamentGrams(v),
v.getStockSpools() == null || v.getStockSpools().doubleValue() <= 0
))
.collect(Collectors.toList());
// Only include material if it has active variants
if (variants.isEmpty()) return null;
if (variants.isEmpty()) {
return null;
}
return new OptionsResponse.MaterialOption(
type.getMaterialCode(),
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"),
variants
);
})
.filter(m -> m != null)
.collect(Collectors.toList());
.toList();
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition")
);
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
);
// 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> l.getIsActive())
.filter(l -> Boolean.TRUE.equals(l.getIsActive()))
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm())
))
.collect(Collectors.toList());
.toList();
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> n.getIsActive())
.filter(n -> Boolean.TRUE.equals(n.getIsActive()))
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(),
String.format("%.1f mm%s", n.getNozzleDiameterMm(),
n.getExtraNozzleChangeFeeChf().doubleValue() > 0
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)")
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)")
))
.collect(Collectors.toList());
.toList();
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
}
// Temporary helper until we add hex to DB
private Set<Long> resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) {
PrinterMachine machine = null;
if (printerMachineId != null) {
machine = printerMachineRepo.findById(printerMachineId).orElse(null);
}
if (machine == null) {
machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null);
}
if (machine == null) {
return Set.of();
}
BigDecimal nozzle = nozzleDiameter != null
? BigDecimal.valueOf(nozzleDiameter)
: BigDecimal.valueOf(0.40);
PrinterMachineProfile machineProfile = orcaProfileResolver
.resolveMachineProfile(machine, nozzle)
.orElse(null);
if (machineProfile == null) {
return Set.of();
}
List<MaterialOrcaProfileMap> maps = materialOrcaMapRepo.findByPrinterMachineProfileAndIsActiveTrue(machineProfile);
return maps.stream()
.map(MaterialOrcaProfileMap::getFilamentMaterialType)
.filter(m -> m != null && m.getId() != null)
.map(FilamentMaterialType::getId)
.collect(Collectors.toSet());
}
private String resolveHexColor(FilamentVariant variant) {
if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) {
return variant.getColorHex();
}
return getColorHex(variant.getColorName());
}
private double toStockFilamentGrams(FilamentVariant variant) {
if (variant.getStockSpools() == null || variant.getSpoolNetKg() == null) {
return 0d;
}
return variant.getStockSpools()
.multiply(variant.getSpoolNetKg())
.multiply(BigDecimal.valueOf(1000))
.doubleValue();
}
private String safeMaterialCode(FilamentMaterialType type) {
if (type == null || type.getMaterialCode() == null) {
return "";
}
return type.getMaterialCode();
}
private String safeString(String value) {
return value == null ? "" : value;
}
// Temporary helper for legacy values where color hex is not yet set in DB
private String getColorHex(String colorName) {
if (colorName == null) {
return "#9e9e9e";
}
String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
@@ -120,6 +218,6 @@ public class OptionsController {
}
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; // Default grey
return "#9e9e9e";
}
}

View File

@@ -11,17 +11,17 @@ import com.printcalculator.service.StorageService;
import com.printcalculator.service.TwintPaymentService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.Map;
@@ -29,10 +29,13 @@ import java.util.HashMap;
import java.util.Base64;
import java.util.stream.Collectors;
import java.net.URI;
import java.util.Locale;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
private final OrderService orderService;
private final OrderRepository orderRepo;
@@ -80,7 +83,7 @@ public class OrderController {
@Transactional
public ResponseEntity<OrderDto> createOrderFromQuote(
@PathVariable UUID quoteSessionId,
@RequestBody com.printcalculator.dto.CreateOrderRequest request
@Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request
) {
Order order = orderService.createOrderFromQuote(quoteSessionId, request);
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
@@ -103,15 +106,21 @@ public class OrderController {
}
String relativePath = item.getStoredRelativePath();
Path destinationRelativePath;
if (relativePath == null || relativePath.equals("PENDING")) {
String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID().toString() + "." + ext;
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
item.setStoredRelativePath(relativePath);
String storedFilename = UUID.randomUUID() + "." + ext;
destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename);
item.setStoredRelativePath(destinationRelativePath.toString());
item.setStoredFilename(storedFilename);
} else {
destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (destinationRelativePath == null) {
return ResponseEntity.badRequest().build();
}
}
storageService.store(file, Paths.get(relativePath));
storageService.store(file, destinationRelativePath);
item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType());
orderItemRepo.save(item);
@@ -140,87 +149,68 @@ public class OrderController {
return getOrder(orderId);
}
@GetMapping("/{orderId}/confirmation")
public ResponseEntity<byte[]> getConfirmation(@PathVariable UUID orderId) {
return generateDocument(orderId, true);
}
@GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
// Paid invoices are sent by email after back-office payment confirmation.
// The public endpoint must not expose a "paid" invoice download.
return ResponseEntity.notFound().build();
}
private ResponseEntity<byte[]> generateDocument(UUID orderId, boolean isConfirmation) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
String qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// Fallback to on-the-fly generation if the stored file is missing or unreadable.
}
}
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String typePrefix = isConfirmation ? "confirmation-" : "invoice-";
String truncatedUuid = order.getId().toString().substring(0, 8);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
.header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path buildConfirmationPdfRelativePath(Order order) {
return Path.of(
"orders",
order.getId().toString(),
"documents",
"confirmation-" + getDisplayOrderNumber(order) + ".pdf"
);
}
@GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(360);
byte[] qrPng = twintPaymentService.generateQrPng(order, 360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl());
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order));
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
@@ -229,12 +219,13 @@ public class OrderController {
@GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl()))
.location(URI.create(twintPaymentService.getTwintPaymentUrl(order)))
.build();
}
@@ -243,12 +234,13 @@ public class OrderController {
@PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size
) {
if (!orderRepo.existsById(orderId)) {
Order order = orderRepo.findById(orderId).orElse(null);
if (order == null) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(normalizedSize);
byte[] png = twintPaymentService.generateQrPng(order, normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
@@ -257,13 +249,38 @@ public class OrderController {
private String getExtension(String filename) {
if (filename == null) return "stl";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return "stl";
}
int i = cleaned.lastIndexOf('.');
if (i > 0 && i < cleaned.length() - 1) {
String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT);
if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) {
return ext;
}
}
return "stl";
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
@@ -277,6 +294,7 @@ public class OrderController {
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());

View File

@@ -1,31 +1,45 @@
package com.printcalculator.controller;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.OrcaProfileResolver;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.Optional;
import java.util.Locale;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
@@ -33,24 +47,27 @@ import org.springframework.core.io.UrlResource;
@RequestMapping("/api/quote-sessions")
public class QuoteSessionController {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final OrcaProfileResolver orcaProfileResolver;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
// Defaults
private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
QuoteCalculator quoteCalculator,
PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
OrcaProfileResolver orcaProfileResolver,
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService) {
this.sessionRepo = sessionRepo;
@@ -58,6 +75,9 @@ public class QuoteSessionController {
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.orcaProfileResolver = orcaProfileResolver;
this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService;
}
@@ -71,13 +91,13 @@ public class QuoteSessionController {
session.setPricingVersion("v1");
// Default material/settings will be set when items are added or updated?
// For now set safe defaults
session.setMaterialCode("pla_basic");
session.setMaterialCode("PLA");
session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
session.setSetupCostChf(policy != null ? policy.getFixedJobFeeChf() : BigDecimal.ZERO);
session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy));
session = sessionRepo.save(session);
return ResponseEntity.ok(session);
@@ -107,54 +127,54 @@ public class QuoteSessionController {
// 1. Define Persistent Storage Path
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
String storageDir = "storage_quotes/" + session.getId();
Files.createDirectories(Paths.get(storageDir));
Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize();
if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) {
throw new IOException("Invalid quote session storage path");
}
Files.createDirectories(sessionStorageDir);
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
? originalFilename.substring(originalFilename.lastIndexOf("."))
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path persistentPath = Paths.get(storageDir, storedFilename);
String ext = getSafeExtension(originalFilename, "stl");
String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize();
if (!persistentPath.startsWith(sessionStorageDir)) {
throw new IOException("Invalid quote line-item storage path");
}
// Save file
Files.copy(file.getInputStream(), persistentPath);
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING);
}
try {
// Apply Basic/Advanced Logic
applyPrintSettings(settings);
// REAL SLICING
// 1. Pick Machine (default to first active or specific)
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4);
// 2. Pick Profiles
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
// For now assuming display name works or we use a tough default
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
// Ideally: machine.getSlicerProfileName();
// Pick machine (selected machine if provided, otherwise first active)
PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId());
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
if (settings.getMaterial() != null) {
if (settings.getMaterial().toLowerCase().contains("pla")) filamentProfile = "Generic PLA";
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
}
// Resolve selected filament variant
FilamentVariant selectedVariant = resolveFilamentVariant(settings);
String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process
// "standard" -> "0.20mm Standard @BBL A1"
// "draft" -> "0.28mm Extra Draft @BBL A1"
// "high" -> "0.12mm Fine @BBL A1" (approx names, need to be exact for Orca)
// Let's use robust defaults or simple overrides
// Update session global settings from the most recent item added
session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode());
session.setNozzleDiameterMm(nozzleDiameter);
session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2));
session.setInfillPattern(settings.getInfillPattern());
session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20);
session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false);
sessionRepo.save(session);
OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant);
String machineProfile = profiles.machineProfileName();
String filamentProfile = profiles.filamentProfileName();
String processProfile = "standard";
if (settings.getLayerHeight() != null) {
if (settings.getLayerHeight() >= 0.28) processProfile = "0.28mm Extra Draft @BBL A1";
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
if (settings.getLayerHeight() >= 0.28) processProfile = "draft";
else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine";
}
// Build overrides map from settings
@@ -173,16 +193,19 @@ public class QuoteSessionController {
processOverrides
);
Optional<ModelDimensions> modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile());
// 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant);
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
item.setStoredPath(persistentPath.toString()); // SAVE PATH
item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root)
item.setQuantity(1);
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setColorCode(selectedVariant.getColorName());
item.setFilamentVariant(selectedVariant);
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
@@ -191,19 +214,20 @@ public class QuoteSessionController {
// Store breakdown
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("machine_cost", result.getTotalPrice() - result.getSetupCost()); // Approximation?
// Better: QuoteResult could expose detailed breakdown. For now just storing what we have.
breakdown.put("setup_fee", result.getSetupCost());
breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level
breakdown.put("setup_fee", 0);
item.setPricingBreakdown(breakdown);
// Dimensions
// Cannot get bb from GCodeParser yet?
// If GCodeParser doesn't return size, we might defaults or 0.
// Stats has filament used.
// Let's set dummy for now or upgrade parser later.
item.setBoundingBoxXMm(BigDecimal.ZERO);
item.setBoundingBoxYMm(BigDecimal.ZERO);
item.setBoundingBoxZMm(BigDecimal.ZERO);
// Dimensions for shipping/package checks are computed server-side from the uploaded model.
item.setBoundingBoxXMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.xMm()))
.orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO));
item.setBoundingBoxYMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.yMm()))
.orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO));
item.setBoundingBoxZMm(modelDimensions
.map(dim -> BigDecimal.valueOf(dim.zMm()))
.orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
@@ -236,7 +260,7 @@ public class QuoteSessionController {
case "standard":
default:
settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
break;
}
@@ -248,6 +272,60 @@ public class QuoteSessionController {
}
}
private PrinterMachine resolvePrinterMachine(Long printerMachineId) {
if (printerMachineId != null) {
PrinterMachine selected = machineRepo.findById(printerMachineId)
.orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId));
if (!Boolean.TRUE.equals(selected.getIsActive())) {
throw new RuntimeException("Selected printer machine is not active");
}
return selected;
}
return machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) {
if (settings.getFilamentVariantId() != null) {
FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId())
.orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId()));
if (!Boolean.TRUE.equals(variant.getIsActive())) {
throw new RuntimeException("Selected filament variant is not active");
}
return variant;
}
String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial());
FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode)
.orElseGet(() -> materialRepo.findByMaterialCode("PLA")
.orElseThrow(() -> new RuntimeException("Fallback material PLA not configured")));
String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null;
if (requestedColor != null && !requestedColor.isBlank()) {
Optional<FilamentVariant> byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor);
if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) {
return byColor.get();
}
}
return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode));
}
private String normalizeRequestedMaterialCode(String value) {
if (value == null || value.isBlank()) {
return "PLA";
}
return value.trim()
.toUpperCase(Locale.ROOT)
.replace('_', ' ')
.replace('-', ' ')
.replaceAll("\\s+", " ");
}
// 3. Update Line Item
@PatchMapping("/line-items/{lineItemId}")
@Transactional
@@ -299,20 +377,88 @@ public class QuoteSessionController {
List<QuoteLineItem> items = lineItemRepo.findByQuoteSessionId(id);
// Calculate Totals
// Calculate Totals and global session hours
BigDecimal itemsTotal = BigDecimal.ZERO;
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem item : items) {
BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity()));
itemsTotal = itemsTotal.add(lineTotal);
if (item.getPrintTimeSeconds() != null) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
itemsTotal = itemsTotal.add(globalMachineCost);
// Map items to DTO to embed distributed machine cost
List<Map<String, Object>> itemsDto = new ArrayList<>();
for (QuoteLineItem item : items) {
Map<String, Object> dto = new HashMap<>();
dto.put("id", item.getId());
dto.put("originalFilename", item.getOriginalFilename());
dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("status", item.getStatus());
BigDecimal unitPrice = item.getUnitPriceChf();
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()));
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = globalMachineCost.multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP);
unitPrice = unitPrice.add(unitMachineCost);
}
dto.put("unitPriceChf", unitPrice);
itemsDto.add(dto);
}
BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO;
BigDecimal grandTotal = itemsTotal.add(setupFee);
// Calculate shipping cost based on dimensions
boolean exceedsBaseSize = false;
for (QuoteLineItem item : items) {
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
BigDecimal[] dims = {x, y, z};
java.util.Arrays.sort(dims);
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 ||
dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 ||
dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
exceedsBaseSize = true;
break;
}
}
int totalQuantity = items.stream()
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
.sum();
BigDecimal shippingCostChf;
if (exceedsBaseSize) {
shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00);
} else {
shippingCostChf = BigDecimal.valueOf(2.00);
}
BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf);
Map<String, Object> response = new HashMap<>();
response.put("session", session);
response.put("items", items);
response.put("itemsTotalChf", itemsTotal);
response.put("items", itemsDto);
response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost
response.put("shippingCostChf", shippingCostChf);
response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now)
response.put("grandTotalChf", grandTotal);
return ResponseEntity.ok(response);
@@ -335,16 +481,54 @@ public class QuoteSessionController {
return ResponseEntity.notFound().build();
}
Path path = Paths.get(item.getStoredPath());
if (!Files.exists(path)) {
Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId);
if (path == null || !Files.exists(path)) {
return ResponseEntity.notFound().build();
}
org.springframework.core.io.Resource resource = new org.springframework.core.io.UrlResource(path.toUri());
org.springframework.core.io.Resource resource = new UrlResource(path.toUri());
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"")
.body(resource);
}
private String getSafeExtension(String filename, String fallback) {
if (filename == null) {
return fallback;
}
String cleaned = StringUtils.cleanPath(filename);
if (cleaned.contains("..")) {
return fallback;
}
int index = cleaned.lastIndexOf('.');
if (index <= 0 || index >= cleaned.length() - 1) {
return fallback;
}
String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT);
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedPath).normalize();
Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize();
Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize();
if (!resolved.startsWith(expectedSessionRoot)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
}

View File

@@ -0,0 +1,83 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminLoginRequest;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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 java.util.Map;
import java.util.OptionalLong;
@RestController
@RequestMapping("/api/admin/auth")
public class AdminAuthController {
private final AdminSessionService adminSessionService;
private final AdminLoginThrottleService adminLoginThrottleService;
public AdminAuthController(
AdminSessionService adminSessionService,
AdminLoginThrottleService adminLoginThrottleService
) {
this.adminSessionService = adminSessionService;
this.adminLoginThrottleService = adminLoginThrottleService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(
@Valid @RequestBody AdminLoginRequest request,
HttpServletRequest httpRequest,
HttpServletResponse response
) {
String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest);
OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey);
if (remainingLock.isPresent()) {
long retryAfter = remainingLock.getAsLong();
return ResponseEntity.status(429)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
if (!adminSessionService.isPasswordValid(request.getPassword())) {
long retryAfter = adminLoginThrottleService.registerFailure(clientKey);
return ResponseEntity.status(401)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter))
.body(Map.of(
"authenticated", false,
"retryAfterSeconds", retryAfter
));
}
adminLoginThrottleService.reset(clientKey);
String token = adminSessionService.createSessionToken();
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString());
return ResponseEntity.ok(Map.of(
"authenticated", true,
"expiresInMinutes", adminSessionService.getSessionTtlMinutes()
));
}
@PostMapping("/logout")
public ResponseEntity<Map<String, Object>> logout(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString());
return ResponseEntity.ok(Map.of("authenticated", false));
}
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me() {
return ResponseEntity.ok(Map.of("authenticated", true));
}
}

View File

@@ -0,0 +1,355 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminFilamentMaterialTypeDto;
import com.printcalculator.dto.AdminFilamentVariantDto;
import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest;
import com.printcalculator.dto.AdminUpsertFilamentVariantRequest;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin/filaments")
@Transactional(readOnly = true)
public class AdminFilamentController {
private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999");
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final Set<String> ALLOWED_FINISH_TYPES = Set.of(
"GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL"
);
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final QuoteLineItemRepository quoteLineItemRepo;
private final OrderItemRepository orderItemRepo;
public AdminFilamentController(
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
QuoteLineItemRepository quoteLineItemRepo,
OrderItemRepository orderItemRepo
) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.quoteLineItemRepo = quoteLineItemRepo;
this.orderItemRepo = orderItemRepo;
}
@GetMapping("/materials")
public ResponseEntity<List<AdminFilamentMaterialTypeDto>> getMaterials() {
List<AdminFilamentMaterialTypeDto> response = materialRepo.findAll().stream()
.sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER))
.map(this::toMaterialDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/variants")
public ResponseEntity<List<AdminFilamentVariantDto>> getVariants() {
List<AdminFilamentVariantDto> response = variantRepo.findAll().stream()
.sorted(Comparator
.comparing((FilamentVariant v) -> {
FilamentMaterialType type = v.getFilamentMaterialType();
return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : "";
}, String.CASE_INSENSITIVE_ORDER)
.thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER))
.map(this::toVariantDto)
.toList();
return ResponseEntity.ok(response);
}
@PostMapping("/materials")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> createMaterial(
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, null);
FilamentMaterialType material = new FilamentMaterialType();
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return ResponseEntity.ok(toMaterialDto(saved));
}
@PutMapping("/materials/{materialTypeId}")
@Transactional
public ResponseEntity<AdminFilamentMaterialTypeDto> updateMaterial(
@PathVariable Long materialTypeId,
@RequestBody AdminUpsertFilamentMaterialTypeRequest payload
) {
FilamentMaterialType material = materialRepo.findById(materialTypeId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found"));
String materialCode = normalizeAndValidateMaterialCode(payload);
ensureMaterialCodeAvailable(materialCode, materialTypeId);
applyMaterialPayload(material, payload, materialCode);
FilamentMaterialType saved = materialRepo.save(material);
return ResponseEntity.ok(toMaterialDto(saved));
}
@PostMapping("/variants")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> createVariant(
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null);
FilamentVariant variant = new FilamentVariant();
variant.setCreatedAt(OffsetDateTime.now());
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return ResponseEntity.ok(toVariantDto(saved));
}
@PutMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<AdminFilamentVariantDto> updateVariant(
@PathVariable Long variantId,
@RequestBody AdminUpsertFilamentVariantRequest payload
) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
FilamentMaterialType material = validateAndResolveMaterial(payload);
String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName());
String normalizedColorName = normalizeAndValidateColorName(payload.getColorName());
validateNumericPayload(payload);
ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId);
applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName);
FilamentVariant saved = variantRepo.save(variant);
return ResponseEntity.ok(toVariantDto(saved));
}
@DeleteMapping("/variants/{variantId}")
@Transactional
public ResponseEntity<Void> deleteVariant(@PathVariable Long variantId) {
FilamentVariant variant = variantRepo.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found"));
if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) {
throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted");
}
variantRepo.delete(variant);
return ResponseEntity.noContent().build();
}
private void applyMaterialPayload(
FilamentMaterialType material,
AdminUpsertFilamentMaterialTypeRequest payload,
String normalizedMaterialCode
) {
boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible());
boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical());
String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null
? payload.getTechnicalTypeLabel().trim()
: null;
material.setMaterialCode(normalizedMaterialCode);
material.setIsFlexible(isFlexible);
material.setIsTechnical(isTechnical);
material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank()
? technicalTypeLabel
: null);
}
private void applyVariantPayload(
FilamentVariant variant,
AdminUpsertFilamentVariantRequest payload,
FilamentMaterialType material,
String normalizedDisplayName,
String normalizedColorName
) {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand());
variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName);
variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand);
variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType));
variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial()));
variant.setCostChfPerKg(payload.getCostChfPerKg());
variant.setStockSpools(payload.getStockSpools());
variant.setSpoolNetKg(payload.getSpoolNetKg());
variant.setIsActive(payload.getIsActive() == null || payload.getIsActive());
}
private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) {
if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Material code is required");
}
return payload.getMaterialCode().trim().toUpperCase();
}
private String normalizeAndValidateVariantDisplayName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required");
}
return value.trim();
}
private String normalizeAndValidateColorName(String value) {
if (value == null || value.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Color name is required");
}
return value.trim();
}
private String normalizeAndValidateColorHex(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) {
throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB");
}
return normalized.toUpperCase(Locale.ROOT);
}
private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) {
String normalized = finishType == null || finishType.isBlank()
? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY")
: finishType.trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_FINISH_TYPES.contains(normalized)) {
throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type");
}
return normalized;
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
}
return materialRepo.findById(payload.getMaterialTypeId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found"));
}
private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) {
if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0");
}
validateNumeric63(payload.getStockSpools(), "Stock spools", true);
validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false);
}
private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) {
if (value == null) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required");
}
if (allowZero) {
if (value.compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0");
}
} else if (value.compareTo(BigDecimal.ZERO) <= 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0");
}
if (value.scale() > 3) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places");
}
if (value.compareTo(MAX_NUMERIC_6_3) > 0) {
throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999");
}
}
private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) {
materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> {
if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) {
throw new ResponseStatusException(BAD_REQUEST, "Material code already exists");
}
});
}
private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) {
variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> {
if (currentVariantId == null || !existing.getId().equals(currentVariantId)) {
throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material");
}
});
}
private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) {
AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto();
dto.setId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setIsFlexible(material.getIsFlexible());
dto.setIsTechnical(material.getIsTechnical());
dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel());
return dto;
}
private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) {
AdminFilamentVariantDto dto = new AdminFilamentVariantDto();
dto.setId(variant.getId());
FilamentMaterialType material = variant.getFilamentMaterialType();
if (material != null) {
dto.setMaterialTypeId(material.getId());
dto.setMaterialCode(material.getMaterialCode());
dto.setMaterialIsFlexible(material.getIsFlexible());
dto.setMaterialIsTechnical(material.getIsTechnical());
dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel());
}
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand());
dto.setIsMatte(variant.getIsMatte());
dto.setIsSpecial(variant.getIsSpecial());
dto.setCostChfPerKg(variant.getCostChfPerKg());
dto.setStockSpools(variant.getStockSpools());
dto.setSpoolNetKg(variant.getSpoolNetKg());
BigDecimal stockKg = BigDecimal.ZERO;
if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) {
stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg());
}
dto.setStockKg(stockKg);
dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000)));
dto.setIsActive(variant.getIsActive());
dto.setCreatedAt(variant.getCreatedAt());
return dto;
}
}

View File

@@ -0,0 +1,372 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminContactRequestDto;
import com.printcalculator.dto.AdminContactRequestAttachmentDto;
import com.printcalculator.dto.AdminContactRequestDetailDto;
import com.printcalculator.dto.AdminFilamentStockDto;
import com.printcalculator.dto.AdminQuoteSessionDto;
import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest;
import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantStockKg;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.FilamentVariantStockKgRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
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 org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.math.BigDecimal;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin")
@Transactional(readOnly = true)
public class AdminOperationsController {
private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class);
private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Set<String> CONTACT_REQUEST_ALLOWED_STATUSES = Set.of(
"NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED"
);
private final FilamentVariantStockKgRepository filamentStockRepo;
private final FilamentVariantRepository filamentVariantRepo;
private final CustomQuoteRequestRepository customQuoteRequestRepo;
private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo;
private final QuoteSessionRepository quoteSessionRepo;
private final OrderRepository orderRepo;
public AdminOperationsController(
FilamentVariantStockKgRepository filamentStockRepo,
FilamentVariantRepository filamentVariantRepo,
CustomQuoteRequestRepository customQuoteRequestRepo,
CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo,
QuoteSessionRepository quoteSessionRepo,
OrderRepository orderRepo
) {
this.filamentStockRepo = filamentStockRepo;
this.filamentVariantRepo = filamentVariantRepo;
this.customQuoteRequestRepo = customQuoteRequestRepo;
this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo;
this.quoteSessionRepo = quoteSessionRepo;
this.orderRepo = orderRepo;
}
@GetMapping("/filament-stock")
public ResponseEntity<List<AdminFilamentStockDto>> getFilamentStock() {
List<FilamentVariantStockKg> stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg"));
Set<Long> variantIds = stocks.stream()
.map(FilamentVariantStockKg::getFilamentVariantId)
.collect(Collectors.toSet());
Map<Long, FilamentVariant> variantsById;
if (variantIds.isEmpty()) {
variantsById = Collections.emptyMap();
} else {
variantsById = filamentVariantRepo.findAllById(variantIds).stream()
.collect(Collectors.toMap(FilamentVariant::getId, variant -> variant));
}
List<AdminFilamentStockDto> response = stocks.stream().map(stock -> {
FilamentVariant variant = variantsById.get(stock.getFilamentVariantId());
AdminFilamentStockDto dto = new AdminFilamentStockDto();
dto.setFilamentVariantId(stock.getFilamentVariantId());
dto.setStockSpools(stock.getStockSpools());
dto.setSpoolNetKg(stock.getSpoolNetKg());
dto.setStockKg(stock.getStockKg());
BigDecimal grams = stock.getStockKg() != null
? stock.getStockKg().multiply(BigDecimal.valueOf(1000))
: BigDecimal.ZERO;
dto.setStockFilamentGrams(grams);
if (variant != null) {
dto.setMaterialCode(
variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: "UNKNOWN"
);
dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName());
dto.setActive(variant.getIsActive());
} else {
dto.setMaterialCode("UNKNOWN");
dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId());
dto.setColorName("-");
dto.setActive(false);
}
return dto;
}).toList();
return ResponseEntity.ok(response);
}
@GetMapping("/contact-requests")
public ResponseEntity<List<AdminContactRequestDto>> getContactRequests() {
List<AdminContactRequestDto> response = customQuoteRequestRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toContactRequestDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/contact-requests/{requestId}")
public ResponseEntity<AdminContactRequestDetailDto> getContactRequestDetail(@PathVariable UUID requestId) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return ResponseEntity.ok(toContactRequestDetailDto(request, attachments));
}
@PatchMapping("/contact-requests/{requestId}/status")
@Transactional
public ResponseEntity<AdminContactRequestDetailDto> updateContactRequestStatus(
@PathVariable UUID requestId,
@RequestBody AdminUpdateContactRequestStatusRequest payload
) {
CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found"));
String requestedStatus = payload != null && payload.getStatus() != null
? payload.getStatus().trim().toUpperCase(Locale.ROOT)
: "";
if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES)
);
}
request.setStatus(requestedStatus);
request.setUpdatedAt(OffsetDateTime.now());
CustomQuoteRequest saved = customQuoteRequestRepo.save(request);
List<AdminContactRequestAttachmentDto> attachments = customQuoteRequestAttachmentRepo
.findByRequest_IdOrderByCreatedAtAsc(requestId)
.stream()
.map(this::toContactRequestAttachmentDto)
.toList();
return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments));
}
@GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file")
public ResponseEntity<Resource> downloadContactRequestAttachment(
@PathVariable UUID requestId,
@PathVariable UUID attachmentId
) {
CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found"));
if (!attachment.getRequest().getId().equals(requestId)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request");
}
String relativePath = attachment.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/";
if (!relativePath.startsWith(expectedPrefix)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize();
if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
if (!Files.exists(filePath)) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
try {
Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM;
String mimeType = attachment.getMimeType();
if (mimeType != null && !mimeType.isBlank()) {
try {
mediaType = MediaType.parseMediaType(mimeType);
} catch (Exception ignored) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = attachment.getOriginalFilename();
if (filename == null || filename.isBlank()) {
filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank()
? attachment.getStoredFilename()
: "attachment-" + attachmentId;
}
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (MalformedURLException e) {
throw new ResponseStatusException(NOT_FOUND, "Attachment file not available");
}
}
@GetMapping("/sessions")
public ResponseEntity<List<AdminQuoteSessionDto>> getQuoteSessions() {
List<AdminQuoteSessionDto> response = quoteSessionRepo.findAll(
Sort.by(Sort.Direction.DESC, "createdAt")
)
.stream()
.map(this::toQuoteSessionDto)
.toList();
return ResponseEntity.ok(response);
}
@DeleteMapping("/sessions/{sessionId}")
@Transactional
public ResponseEntity<Void> deleteQuoteSession(@PathVariable UUID sessionId) {
QuoteSession session = quoteSessionRepo.findById(sessionId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found"));
if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) {
throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order");
}
deleteSessionFiles(sessionId);
quoteSessionRepo.delete(session);
return ResponseEntity.noContent().build();
}
private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) {
AdminContactRequestDto dto = new AdminContactRequestDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
return dto;
}
private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) {
AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto();
dto.setId(attachment.getId());
dto.setOriginalFilename(attachment.getOriginalFilename());
dto.setMimeType(attachment.getMimeType());
dto.setFileSizeBytes(attachment.getFileSizeBytes());
dto.setCreatedAt(attachment.getCreatedAt());
return dto;
}
private AdminContactRequestDetailDto toContactRequestDetailDto(
CustomQuoteRequest request,
List<AdminContactRequestAttachmentDto> attachments
) {
AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto();
dto.setId(request.getId());
dto.setRequestType(request.getRequestType());
dto.setCustomerType(request.getCustomerType());
dto.setEmail(request.getEmail());
dto.setPhone(request.getPhone());
dto.setName(request.getName());
dto.setCompanyName(request.getCompanyName());
dto.setContactPerson(request.getContactPerson());
dto.setMessage(request.getMessage());
dto.setStatus(request.getStatus());
dto.setCreatedAt(request.getCreatedAt());
dto.setUpdatedAt(request.getUpdatedAt());
dto.setAttachments(attachments);
return dto;
}
private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId());
dto.setStatus(session.getStatus());
dto.setMaterialCode(session.getMaterialCode());
dto.setCreatedAt(session.getCreatedAt());
dto.setExpiresAt(session.getExpiresAt());
dto.setConvertedOrderId(session.getConvertedOrderId());
return dto;
}
private void deleteSessionFiles(UUID sessionId) {
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
if (!Files.exists(sessionDir)) {
return;
}
try (Stream<Path> walk = Files.walk(sessionDir)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException | UncheckedIOException e) {
logger.error("Failed to delete files for session {}", sessionId, e);
throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files");
}
}
}

View File

@@ -0,0 +1,327 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.dto.OrderItemDto;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/admin/orders")
@Transactional(readOnly = true)
public class AdminOrderController {
private static final List<String> ALLOWED_ORDER_STATUSES = List.of(
"PENDING_PAYMENT",
"PAID",
"IN_PRODUCTION",
"SHIPPED",
"COMPLETED",
"CANCELLED"
);
private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo;
private final PaymentRepository paymentRepo;
private final PaymentService paymentService;
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
public AdminOrderController(
OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
PaymentRepository paymentRepo,
PaymentService paymentService,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService
) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.paymentRepo = paymentRepo;
this.paymentService = paymentService;
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
}
@GetMapping
public ResponseEntity<List<OrderDto>> listOrders() {
List<OrderDto> response = orderRepo.findAllByOrderByCreatedAtDesc()
.stream()
.map(this::toOrderDto)
.toList();
return ResponseEntity.ok(response);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable UUID orderId) {
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/payments/confirm")
@Transactional
public ResponseEntity<OrderDto> confirmPayment(
@PathVariable UUID orderId,
@RequestBody(required = false) Map<String, String> payload
) {
getOrderOrThrow(orderId);
String method = payload != null ? payload.get("method") : null;
paymentService.confirmPayment(orderId, method);
return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId)));
}
@PostMapping("/{orderId}/status")
@Transactional
public ResponseEntity<OrderDto> updateOrderStatus(
@PathVariable UUID orderId,
@RequestBody AdminOrderStatusUpdateRequest payload
) {
if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required");
}
Order order = getOrderOrThrow(orderId);
String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT);
if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) {
throw new ResponseStatusException(
BAD_REQUEST,
"Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES)
);
}
order.setStatus(normalizedStatus);
orderRepo.save(order);
return ResponseEntity.ok(toOrderDto(order));
}
@GetMapping("/{orderId}/items/{orderItemId}/file")
public ResponseEntity<Resource> downloadOrderItemFile(
@PathVariable UUID orderId,
@PathVariable UUID orderItemId
) {
OrderItem item = orderItemRepo.findById(orderItemId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found"));
if (!item.getOrder().getId().equals(orderId)) {
throw new ResponseStatusException(NOT_FOUND, "Order item not found for order");
}
String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try {
Resource resource = storageService.loadAsResource(safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(item.getMimeType());
} catch (Exception ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank()
? item.getOriginalFilename()
: "order-item-" + orderItemId;
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
} catch (Exception e) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
}
@GetMapping("/{orderId}/documents/confirmation")
public ResponseEntity<byte[]> downloadOrderConfirmation(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), true);
}
@GetMapping("/{orderId}/documents/invoice")
public ResponseEntity<byte[]> downloadOrderInvoice(@PathVariable UUID orderId) {
return generateDocument(getOrderOrThrow(orderId), false);
}
private Order getOrderOrThrow(UUID orderId) {
return orderRepo.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found"));
}
private OrderDto toOrderDto(Order order) {
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setPreferredLanguage(order.getPreferredLanguage());
dto.setBillingCustomerType(order.getBillingCustomerType());
dto.setCurrency(order.getCurrency());
dto.setSetupCostChf(order.getSetupCostChf());
dto.setShippingCostChf(order.getShippingCostChf());
dto.setDiscountChf(order.getDiscountChf());
dto.setSubtotalChf(order.getSubtotalChf());
dto.setTotalChf(order.getTotalChf());
dto.setCreatedAt(order.getCreatedAt());
dto.setShippingSameAsBilling(order.getShippingSameAsBilling());
QuoteSession sourceSession = order.getSourceQuoteSession();
if (sourceSession != null) {
dto.setPrintMaterialCode(sourceSession.getMaterialCode());
dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm());
dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm());
dto.setPrintInfillPattern(sourceSession.getInfillPattern());
dto.setPrintInfillPercent(sourceSession.getInfillPercent());
dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled());
}
AddressDto billing = new AddressDto();
billing.setFirstName(order.getBillingFirstName());
billing.setLastName(order.getBillingLastName());
billing.setCompanyName(order.getBillingCompanyName());
billing.setContactPerson(order.getBillingContactPerson());
billing.setAddressLine1(order.getBillingAddressLine1());
billing.setAddressLine2(order.getBillingAddressLine2());
billing.setZip(order.getBillingZip());
billing.setCity(order.getBillingCity());
billing.setCountryCode(order.getBillingCountryCode());
dto.setBillingAddress(billing);
if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) {
AddressDto shipping = new AddressDto();
shipping.setFirstName(order.getShippingFirstName());
shipping.setLastName(order.getShippingLastName());
shipping.setCompanyName(order.getShippingCompanyName());
shipping.setContactPerson(order.getShippingContactPerson());
shipping.setAddressLine1(order.getShippingAddressLine1());
shipping.setAddressLine2(order.getShippingAddressLine2());
shipping.setZip(order.getShippingZip());
shipping.setCity(order.getShippingCity());
shipping.setCountryCode(order.getShippingCountryCode());
dto.setShippingAddress(shipping);
}
List<OrderItemDto> itemDtos = items.stream().map(i -> {
OrderItemDto idto = new OrderItemDto();
idto.setId(i.getId());
idto.setOriginalFilename(i.getOriginalFilename());
idto.setMaterialCode(i.getMaterialCode());
idto.setColorCode(i.getColorCode());
idto.setQuantity(i.getQuantity());
idto.setPrintTimeSeconds(i.getPrintTimeSeconds());
idto.setMaterialGrams(i.getMaterialGrams());
idto.setUnitPriceChf(i.getUnitPriceChf());
idto.setLineTotalChf(i.getLineTotalChf());
return idto;
}).toList();
dto.setItems(itemDtos);
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) {
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try {
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(existingPdf);
} catch (Exception ignored) {
// fallback to generated confirmation document
}
}
List<OrderItem> items = orderItemRepo.findByOrder_Id(order.getId());
Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null);
byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment);
String prefix = isConfirmation ? "confirmation-" : "invoice-";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
}

View File

@@ -0,0 +1,52 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestAttachmentDto {
private UUID id;
private String originalFilename;
private String mimeType;
private Long fileSizeBytes;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,125 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminContactRequestDetailDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String contactPerson;
private String message;
private String status;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private List<AdminContactRequestAttachmentDto> attachments;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getContactPerson() {
return contactPerson;
}
public void setContactPerson(String contactPerson) {
this.contactPerson = contactPerson;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public List<AdminContactRequestAttachmentDto> getAttachments() {
return attachments;
}
public void setAttachments(List<AdminContactRequestAttachmentDto> attachments) {
this.attachments = attachments;
}
}

View File

@@ -0,0 +1,88 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminContactRequestDto {
private UUID id;
private String requestType;
private String customerType;
private String email;
private String phone;
private String name;
private String companyName;
private String status;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getRequestType() {
return requestType;
}
public void setRequestType(String requestType) {
this.requestType = requestType;
}
public String getCustomerType() {
return customerType;
}
public void setCustomerType(String customerType) {
this.customerType = customerType;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,49 @@
package com.printcalculator.dto;
public class AdminFilamentMaterialTypeDto {
private Long id;
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,87 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminFilamentStockDto {
private Long filamentVariantId;
private String materialCode;
private String variantDisplayName;
private String colorName;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean active;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public void setFilamentVariantId(Long filamentVariantId) {
this.filamentVariantId = filamentVariantId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getActive() {
return active;
}
public void setActive(Boolean active) {
this.active = active;
}
}

View File

@@ -0,0 +1,187 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public class AdminFilamentVariantDto {
private Long id;
private Long materialTypeId;
private String materialCode;
private Boolean materialIsFlexible;
private Boolean materialIsTechnical;
private String materialTechnicalTypeLabel;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private BigDecimal stockKg;
private BigDecimal stockFilamentGrams;
private Boolean isActive;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getMaterialIsFlexible() {
return materialIsFlexible;
}
public void setMaterialIsFlexible(Boolean materialIsFlexible) {
this.materialIsFlexible = materialIsFlexible;
}
public Boolean getMaterialIsTechnical() {
return materialIsTechnical;
}
public void setMaterialIsTechnical(Boolean materialIsTechnical) {
this.materialIsTechnical = materialIsTechnical;
}
public String getMaterialTechnicalTypeLabel() {
return materialTechnicalTypeLabel;
}
public void setMaterialTechnicalTypeLabel(String materialTechnicalTypeLabel) {
this.materialTechnicalTypeLabel = materialTechnicalTypeLabel;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
public void setStockKg(BigDecimal stockKg) {
this.stockKg = stockKg;
}
public BigDecimal getStockFilamentGrams() {
return stockFilamentGrams;
}
public void setStockFilamentGrams(BigDecimal stockFilamentGrams) {
this.stockFilamentGrams = stockFilamentGrams;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.NotBlank;
public class AdminLoginRequest {
@NotBlank
private String password;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.dto;
public class AdminOrderStatusUpdateRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,61 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminQuoteSessionDto {
private UUID id;
private String status;
private String materialCode;
private OffsetDateTime createdAt;
private OffsetDateTime expiresAt;
private UUID convertedOrderId;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(OffsetDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public UUID getConvertedOrderId() {
return convertedOrderId;
}
public void setConvertedOrderId(UUID convertedOrderId) {
this.convertedOrderId = convertedOrderId;
}
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.dto;
public class AdminUpdateContactRequestStatusRequest {
private String status;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,40 @@
package com.printcalculator.dto;
public class AdminUpsertFilamentMaterialTypeRequest {
private String materialCode;
private Boolean isFlexible;
private Boolean isTechnical;
private String technicalTypeLabel;
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,114 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId;
private String variantDisplayName;
private String colorName;
private String colorHex;
private String finishType;
private String brand;
private Boolean isMatte;
private Boolean isSpecial;
private BigDecimal costChfPerKg;
private BigDecimal stockSpools;
private BigDecimal spoolNetKg;
private Boolean isActive;
public Long getMaterialTypeId() {
return materialTypeId;
}
public void setMaterialTypeId(Long materialTypeId) {
this.materialTypeId = materialTypeId;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -1,11 +1,19 @@
package com.printcalculator.dto;
import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data
public class CreateOrderRequest {
private CustomerDto customer;
private AddressDto billingAddress;
private AddressDto shippingAddress;
private String language;
private boolean shippingSameAsBilling;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
}

View File

@@ -10,7 +10,16 @@ public record OptionsResponse(
List<NozzleOptionDTO> nozzleDiameters
) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
public record VariantOption(
Long id,
String name,
String colorName,
String hexColor,
String finishType,
Double stockSpools,
Double stockFilamentGrams,
boolean isOutOfStock
) {}
public record QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {}

View File

@@ -13,6 +13,7 @@ public class OrderDto {
private String paymentMethod;
private String customerEmail;
private String customerPhone;
private String preferredLanguage;
private String billingCustomerType;
private AddressDto billingAddress;
private AddressDto shippingAddress;
@@ -24,6 +25,12 @@ public class OrderDto {
private BigDecimal subtotalChf;
private BigDecimal totalChf;
private OffsetDateTime createdAt;
private String printMaterialCode;
private BigDecimal printNozzleDiameterMm;
private BigDecimal printLayerHeightMm;
private String printInfillPattern;
private Integer printInfillPercent;
private Boolean printSupportsEnabled;
private List<OrderItemDto> items;
// Getters and Setters
@@ -48,6 +55,9 @@ public class OrderDto {
public String getCustomerPhone() { return customerPhone; }
public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; }
public String getPreferredLanguage() { return preferredLanguage; }
public void setPreferredLanguage(String preferredLanguage) { this.preferredLanguage = preferredLanguage; }
public String getBillingCustomerType() { return billingCustomerType; }
public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; }
@@ -81,6 +91,24 @@ public class OrderDto {
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public String getPrintMaterialCode() { return printMaterialCode; }
public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; }
public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; }
public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; }
public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; }
public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; }
public String getPrintInfillPattern() { return printInfillPattern; }
public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; }
public Integer getPrintInfillPercent() { return printInfillPercent; }
public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; }
public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; }
public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; }
public List<OrderItemDto> getItems() { return items; }
public void setItems(List<OrderItemDto> items) { this.items = items; }
}

View File

@@ -8,16 +8,24 @@ public class PrintSettingsDto {
private String complexityMode;
// Common
private String material; // e.g. "PLA", "PETG"
private String material; // e.g. "PLA", "PLA TOUGH", "PETG"
private String color; // e.g. "White", "#FFFFFF"
private Long filamentVariantId;
private Long printerMachineId;
// Basic Mode
private String quality; // "draft", "standard", "high"
// Advanced Mode (Optional in Basic)
private Double nozzleDiameter;
private Double layerHeight;
private Double infillDensity;
private String infillPattern;
private Boolean supportsEnabled;
private String notes;
// Dimensions
private Double boundingBoxX;
private Double boundingBoxY;
private Double boundingBoxZ;
}

View File

@@ -1,6 +1,7 @@
package com.printcalculator.dto;
import lombok.Data;
import jakarta.validation.constraints.AssertTrue;
@Data
public class QuoteRequestDto {
@@ -12,4 +13,10 @@ public class QuoteRequestDto {
private String companyName;
private String contactPerson;
private String message;
@AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.")
private boolean acceptTerms;
@AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.")
private boolean acceptPrivacy;
}

View File

@@ -24,6 +24,16 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName;
@Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex;
@ColumnDefault("'GLOSSY'")
@Column(name = "finish_type", length = Integer.MAX_VALUE)
private String finishType;
@Column(name = "brand", length = Integer.MAX_VALUE)
private String brand;
@ColumnDefault("false")
@Column(name = "is_matte", nullable = false)
private Boolean isMatte;
@@ -83,6 +93,30 @@ public class FilamentVariant {
this.colorName = colorName;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getFinishType() {
return finishType;
}
public void setFinishType(String finishType) {
this.finishType = finishType;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public Boolean getIsMatte() {
return isMatte;
}

View File

@@ -0,0 +1,72 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "filament_variant_orca_override", uniqueConstraints = {
@UniqueConstraint(name = "ux_filament_variant_orca_override_variant_machine", columnNames = {
"filament_variant_id", "printer_machine_profile_id"
})
})
public class FilamentVariantOrcaOverride {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_variant_orca_override_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_variant_id", nullable = false)
private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,72 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "material_orca_profile_map", uniqueConstraints = {
@UniqueConstraint(name = "ux_material_orca_profile_map_machine_material", columnNames = {
"printer_machine_profile_id", "filament_material_type_id"
})
})
public class MaterialOrcaProfileMap {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "material_orca_profile_map_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_profile_id", nullable = false)
private PrinterMachineProfile printerMachineProfile;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_material_type_id", nullable = false)
private FilamentMaterialType filamentMaterialType;
@Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaFilamentProfileName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachineProfile getPrinterMachineProfile() {
return printerMachineProfile;
}
public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) {
this.printerMachineProfile = printerMachineProfile;
}
public FilamentMaterialType getFilamentMaterialType() {
return filamentMaterialType;
}
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
this.filamentMaterialType = filamentMaterialType;
}
public String getOrcaFilamentProfileName() {
return orcaFilamentProfileName;
}
public void setOrcaFilamentProfileName(String orcaFilamentProfileName) {
this.orcaFilamentProfileName = orcaFilamentProfileName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -95,6 +95,10 @@ public class Order {
@Column(name = "shipping_country_code", length = 2)
private String shippingCountryCode;
@ColumnDefault("'it'")
@Column(name = "preferred_language", length = 2)
private String preferredLanguage;
@ColumnDefault("'CHF'")
@Column(name = "currency", nullable = false, length = 3)
private String currency;
@@ -356,6 +360,14 @@ public class Order {
this.currency = currency;
}
public String getPreferredLanguage() {
return preferredLanguage;
}
public void setPreferredLanguage(String preferredLanguage) {
this.preferredLanguage = preferredLanguage;
}
public BigDecimal getSetupCostChf() {
return setupCostChf;
}

View File

@@ -44,6 +44,10 @@ public class OrderItem {
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant;
@Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode;
@@ -57,6 +61,15 @@ public class OrderItem {
@Column(name = "material_grams", precision = 12, scale = 2)
private BigDecimal materialGrams;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm;
@Column(name = "bounding_box_y_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxYMm;
@Column(name = "bounding_box_z_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxZMm;
@Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal unitPriceChf;
@@ -149,6 +162,14 @@ public class OrderItem {
this.materialCode = materialCode;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public String getColorCode() {
return colorCode;
}
@@ -181,6 +202,30 @@ public class OrderItem {
this.materialGrams = materialGrams;
}
public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm;
}
public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) {
this.boundingBoxXMm = boundingBoxXMm;
}
public BigDecimal getBoundingBoxYMm() {
return boundingBoxYMm;
}
public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) {
this.boundingBoxYMm = boundingBoxYMm;
}
public BigDecimal getBoundingBoxZMm() {
return boundingBoxZMm;
}
public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) {
this.boundingBoxZMm = boundingBoxZMm;
}
public BigDecimal getUnitPriceChf() {
return unitPriceChf;
}

View File

@@ -0,0 +1,85 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "printer_machine_profile", uniqueConstraints = {
@UniqueConstraint(name = "ux_printer_machine_profile_machine_nozzle", columnNames = {
"printer_machine_id", "nozzle_diameter_mm"
})
})
public class PrinterMachineProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "printer_machine_profile_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "printer_machine_id", nullable = false)
private PrinterMachine printerMachine;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@Column(name = "orca_machine_profile_name", nullable = false, length = Integer.MAX_VALUE)
private String orcaMachineProfileName;
@ColumnDefault("false")
@Column(name = "is_default", nullable = false)
private Boolean isDefault;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PrinterMachine getPrinterMachine() {
return printerMachine;
}
public void setPrinterMachine(PrinterMachine printerMachine) {
this.printerMachine = printerMachine;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public String getOrcaMachineProfileName() {
return orcaMachineProfileName;
}
public void setOrcaMachineProfileName(String orcaMachineProfileName) {
this.orcaMachineProfileName = orcaMachineProfileName;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean isDefault) {
this.isDefault = isDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -40,6 +40,11 @@ public class QuoteLineItem {
@Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "filament_variant_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant;
@Column(name = "bounding_box_x_mm", precision = 10, scale = 3)
private BigDecimal boundingBoxXMm;
@@ -124,6 +129,14 @@ public class QuoteLineItem {
this.colorCode = colorCode;
}
public FilamentVariant getFilamentVariant() {
return filamentVariant;
}
public void setFilamentVariant(FilamentVariant filamentVariant) {
this.filamentVariant = filamentVariant;
}
public BigDecimal getBoundingBoxXMm() {
return boundingBoxXMm;
}

View File

@@ -0,0 +1,24 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentConfirmedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentConfirmedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

@@ -1,9 +1,15 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,16 +18,29 @@ import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.text.NumberFormat;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Currency;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.nio.file.Paths;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailListener {
private static final String DEFAULT_LANGUAGE = "it";
private final EmailNotificationService emailNotificationService;
private final InvoicePdfRenderingService invoicePdfRenderingService;
private final OrderItemRepository orderItemRepository;
private final QrBillService qrBillService;
private final StorageService storageService;
@Value("${app.mail.admin.enabled:true}")
private boolean adminMailEnabled;
@@ -62,56 +81,306 @@ public class OrderEmailListener {
}
}
private void sendCustomerConfirmationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
@Async
@EventListener
public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) {
Order order = event.getOrder();
Payment payment = event.getPayment();
log.info("Processing PaymentConfirmedEvent for order id: {}", order.getId());
emailNotificationService.sendEmail(
try {
sendPaidInvoiceEmail(order, payment);
} catch (Exception e) {
log.error("Failed to send paid invoice email for order id: {}", order.getId(), e);
}
}
private void sendCustomerConfirmationEmail(Order order) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyOrderConfirmationTexts(templateData, language, orderNumber);
byte[] confirmationPdf = loadOrGenerateConfirmationPdf(order);
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
"Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab",
subject,
"order-confirmation",
templateData
templateData,
buildConfirmationAttachmentName(language, orderNumber),
confirmationPdf
);
}
private void sendPaymentReportedEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentReportedTexts(templateData, language, orderNumber);
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
"Stiamo verificando il tuo pagamento (Ordine #" + getDisplayOrderNumber(order) + ")",
subject,
"payment-reported",
templateData
);
}
private void sendAdminNotificationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
private void sendPaidInvoiceEmail(Order order, Payment payment) {
String language = resolveLanguage(order.getPreferredLanguage());
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, language);
String subject = applyPaymentConfirmedTexts(templateData, language, orderNumber);
byte[] pdf = null;
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment);
} catch (Exception e) {
log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e);
}
emailNotificationService.sendEmailWithAttachment(
order.getCustomer().getEmail(),
subject,
"payment-confirmed",
templateData,
buildPaidInvoiceAttachmentName(language, orderNumber),
pdf
);
}
private void sendAdminNotificationEmail(Order order) {
String orderNumber = getDisplayOrderNumber(order);
Map<String, Object> templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE);
templateData.put("customerName", buildCustomerFullName(order));
templateData.put("emailTitle", "Nuovo ordine ricevuto");
templateData.put("headlineText", "Nuovo ordine #" + orderNumber);
templateData.put("greetingText", "Ciao team,");
templateData.put("introText", "Un nuovo ordine e' stato creato dal cliente.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Apri dettaglio ordine");
templateData.put("attachmentHintText", "La conferma cliente e il QR bill sono stati salvati nella cartella documenti dell'ordine.");
templateData.put("supportText", "Controlla i dettagli e procedi con la gestione operativa.");
templateData.put("footerText", "Notifica automatica sistema ordini.");
// Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro
emailNotificationService.sendEmail(
adminMailAddress,
"Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(),
"Nuovo Ordine Ricevuto #" + orderNumber + " - " + buildCustomerFullName(order),
"order-confirmation",
templateData
);
}
private Map<String, Object> buildBaseTemplateData(Order order, String language) {
Locale locale = localeForLanguage(language);
NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale);
currencyFormatter.setCurrency(Currency.getInstance("CHF"));
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", buildCustomerFirstName(order, language));
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order, language));
templateData.put(
"orderDate",
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
);
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("currentYear", Year.now().getValue());
return templateData;
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Order Confirmation");
templateData.put("headlineText", "Thank you for your order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your order and started processing it.");
templateData.put("detailsTitleText", "Order details");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("attachmentHintText", "Attached you can find the order confirmation PDF with the QR bill.");
templateData.put("supportText", "If you have questions, reply to this email and we will help you.");
templateData.put("footerText", "Automated message from 3D-Fab.");
yield "Order Confirmation #" + orderNumber + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Bestellbestaetigung");
templateData.put("headlineText", "Danke fuer Ihre Bestellung #" + orderNumber);
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Bestellung erhalten und mit der Bearbeitung begonnen.");
templateData.put("detailsTitleText", "Bestelldetails");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelDate", "Datum");
templateData.put("labelTotal", "Gesamtbetrag");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("attachmentHintText", "Im Anhang finden Sie die Bestellbestaetigung mit QR-Rechnung.");
templateData.put("supportText", "Bei Fragen antworten Sie einfach auf diese E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
yield "Bestellbestaetigung #" + orderNumber + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Confirmation de commande");
templateData.put("headlineText", "Merci pour votre commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre commande et commence son traitement.");
templateData.put("detailsTitleText", "Details de commande");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelDate", "Date");
templateData.put("labelTotal", "Total");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("attachmentHintText", "Vous trouverez en piece jointe la confirmation de commande avec la facture QR.");
templateData.put("supportText", "Si vous avez des questions, repondez a cet email.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
yield "Confirmation de commande #" + orderNumber + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Conferma ordine");
templateData.put("headlineText", "Grazie per il tuo ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto il tuo ordine e iniziato l'elaborazione.");
templateData.put("detailsTitleText", "Dettagli ordine");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelDate", "Data");
templateData.put("labelTotal", "Totale");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("attachmentHintText", "In allegato trovi la conferma ordine in PDF con QR bill.");
templateData.put("supportText", "Se hai domande, rispondi a questa email e ti aiutiamo subito.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
yield "Conferma Ordine #" + orderNumber + " - 3D-Fab";
}
};
}
private String applyPaymentReportedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Reported");
templateData.put("headlineText", "Payment reported for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "We received your payment report and our team is now verifying it.");
templateData.put("statusText", "Current status: Payment under verification.");
templateData.put("orderDetailsCtaText", "Check order status");
templateData.put("supportText", "You will receive another email as soon as the payment is confirmed.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "We are verifying your payment (Order #" + orderNumber + ")";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung gemeldet");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " gemeldet");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Wir haben Ihre Zahlungsmitteilung erhalten und pruefen sie aktuell.");
templateData.put("statusText", "Aktueller Status: Zahlung in Pruefung.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Sobald die Zahlung bestaetigt ist, erhalten Sie eine weitere E-Mail.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Wir pruefen Ihre Zahlung (Bestellung #" + orderNumber + ")";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement signale");
templateData.put("headlineText", "Paiement signale pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Nous avons recu votre signalement de paiement et nous le verifions.");
templateData.put("statusText", "Statut actuel: Paiement en verification.");
templateData.put("orderDetailsCtaText", "Consulter le statut de la commande");
templateData.put("supportText", "Vous recevrez un nouvel email des que le paiement sera confirme.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Nous verifions votre paiement (Commande #" + orderNumber + ")";
}
default -> {
templateData.put("emailTitle", "Pagamento segnalato");
templateData.put("headlineText", "Pagamento segnalato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Abbiamo ricevuto la tua segnalazione di pagamento e la stiamo verificando.");
templateData.put("statusText", "Stato attuale: pagamento in verifica.");
templateData.put("orderDetailsCtaText", "Controlla lo stato ordine");
templateData.put("supportText", "Riceverai una nuova email non appena il pagamento sara' confermato.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Stiamo verificando il tuo pagamento (Ordine #" + orderNumber + ")";
}
};
}
private String applyPaymentConfirmedTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Payment Confirmed");
templateData.put("headlineText", "Payment confirmed for order #" + orderNumber);
templateData.put("greetingText", "Hi " + templateData.get("customerName") + ",");
templateData.put("introText", "Your payment has been confirmed and the order moved into production.");
templateData.put("statusText", "Current status: In production.");
templateData.put("attachmentHintText", "The paid invoice PDF is attached to this email.");
templateData.put("orderDetailsCtaText", "View order status");
templateData.put("supportText", "We will notify you again when the shipment is ready.");
templateData.put("footerText", "Automated message from 3D-Fab.");
templateData.put("labelOrderNumber", "Order number");
templateData.put("labelTotal", "Total");
yield "Payment confirmed (Order #" + orderNumber + ") - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Zahlung bestaetigt");
templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " bestaetigt");
templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ",");
templateData.put("introText", "Ihre Zahlung wurde bestaetigt und die Bestellung ist jetzt in Produktion.");
templateData.put("statusText", "Aktueller Status: In Produktion.");
templateData.put("attachmentHintText", "Die bezahlte Rechnung als PDF ist dieser E-Mail beigefuegt.");
templateData.put("orderDetailsCtaText", "Bestellstatus ansehen");
templateData.put("supportText", "Wir informieren Sie erneut, sobald der Versand bereit ist.");
templateData.put("footerText", "Automatische Nachricht von 3D-Fab.");
templateData.put("labelOrderNumber", "Bestellnummer");
templateData.put("labelTotal", "Gesamtbetrag");
yield "Zahlung bestaetigt (Bestellung #" + orderNumber + ") - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Paiement confirme");
templateData.put("headlineText", "Paiement confirme pour la commande #" + orderNumber);
templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ",");
templateData.put("introText", "Votre paiement est confirme et la commande est passe en production.");
templateData.put("statusText", "Statut actuel: En production.");
templateData.put("attachmentHintText", "La facture payee en PDF est jointe a cet email.");
templateData.put("orderDetailsCtaText", "Voir le statut de la commande");
templateData.put("supportText", "Nous vous informerons a nouveau des que l'expedition sera prete.");
templateData.put("footerText", "Message automatique de 3D-Fab.");
templateData.put("labelOrderNumber", "Numero de commande");
templateData.put("labelTotal", "Total");
yield "Paiement confirme (Commande #" + orderNumber + ") - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Pagamento confermato");
templateData.put("headlineText", "Pagamento confermato per ordine #" + orderNumber);
templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ",");
templateData.put("introText", "Il tuo pagamento e' stato confermato e l'ordine e' entrato in produzione.");
templateData.put("statusText", "Stato attuale: in produzione.");
templateData.put("attachmentHintText", "In allegato trovi la fattura saldata in PDF.");
templateData.put("orderDetailsCtaText", "Visualizza stato ordine");
templateData.put("supportText", "Ti aggiorneremo di nuovo quando la spedizione sara' pronta.");
templateData.put("footerText", "Messaggio automatico di 3D-Fab.");
templateData.put("labelOrderNumber", "Numero ordine");
templateData.put("labelTotal", "Totale");
yield "Pagamento confermato (Ordine #" + orderNumber + ") - 3D-Fab";
}
};
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
@@ -120,8 +389,108 @@ public class OrderEmailListener {
return order.getId() != null ? order.getId().toString() : "unknown";
}
private String buildOrderDetailsUrl(Order order) {
private String buildOrderDetailsUrl(Order order, String language) {
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/ordine/" + order.getId();
return baseUrl + "/" + language + "/co/" + order.getId();
}
private String buildConfirmationAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Order-Confirmation-" + orderNumber + ".pdf";
case "de" -> "Bestellbestaetigung-" + orderNumber + ".pdf";
case "fr" -> "Confirmation-Commande-" + orderNumber + ".pdf";
default -> "Conferma-Ordine-" + orderNumber + ".pdf";
};
}
private String buildPaidInvoiceAttachmentName(String language, String orderNumber) {
return switch (language) {
case "en" -> "Paid-Invoice-" + orderNumber + ".pdf";
case "de" -> "Bezahlte-Rechnung-" + orderNumber + ".pdf";
case "fr" -> "Facture-Payee-" + orderNumber + ".pdf";
default -> "Fattura-Pagata-" + orderNumber + ".pdf";
};
}
private byte[] loadOrGenerateConfirmationPdf(Order order) {
byte[] stored = loadStoredConfirmationPdf(order);
if (stored != null) {
return stored;
}
try {
List<OrderItem> items = orderItemRepository.findByOrder_Id(order.getId());
return invoicePdfRenderingService.generateDocumentPdf(order, items, true, qrBillService, null);
} catch (Exception e) {
log.error("Failed to generate fallback confirmation PDF for order id: {}", order.getId(), e);
return null;
}
}
private byte[] loadStoredConfirmationPdf(Order order) {
String relativePath = buildConfirmationPdfRelativePath(order);
try {
return storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
} catch (Exception e) {
log.warn("Confirmation PDF not found for order id {} at {}", order.getId(), relativePath);
return null;
}
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String buildCustomerFirstName(Order order, String language) {
if (order.getCustomer() != null && order.getCustomer().getFirstName() != null && !order.getCustomer().getFirstName().isBlank()) {
return order.getCustomer().getFirstName();
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()) {
return order.getBillingFirstName();
}
return switch (language) {
case "en" -> "Customer";
case "de" -> "Kunde";
case "fr" -> "Client";
default -> "Cliente";
};
}
private String buildCustomerFullName(Order order) {
String firstName = order.getCustomer() != null ? order.getCustomer().getFirstName() : null;
String lastName = order.getCustomer() != null ? order.getCustomer().getLastName() : null;
if (firstName != null && !firstName.isBlank() && lastName != null && !lastName.isBlank()) {
return firstName + " " + lastName;
}
if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()
&& order.getBillingLastName() != null && !order.getBillingLastName().isBlank()) {
return order.getBillingFirstName() + " " + order.getBillingLastName();
}
return "Cliente";
}
private Locale localeForLanguage(String language) {
return switch (language) {
case "en" -> Locale.ENGLISH;
case "de" -> Locale.GERMAN;
case "fr" -> Locale.FRENCH;
default -> Locale.ITALIAN;
};
}
private String resolveLanguage(String language) {
if (language == null || language.isBlank()) {
return DEFAULT_LANGUAGE;
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> DEFAULT_LANGUAGE;
};
}
}

View File

@@ -2,12 +2,16 @@ package com.printcalculator.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ControllerAdvice
@@ -24,4 +28,34 @@ public class GlobalExceptionHandler {
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Object> handleValidationException(
MethodArgumentNotValidException ex, WebRequest request) {
List<String> details = new ArrayList<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
details.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "Dati non validi.");
body.put("error", "Validation Error");
body.put("details", details);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Object> handleIllegalArgumentException(
IllegalArgumentException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Bad Request");
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.model;
public record ModelDimensions(
double xMm,
double yMm,
double zMm
) {}

View File

@@ -4,13 +4,10 @@ public class QuoteResult {
private double totalPrice;
private String currency;
private PrintStats stats;
private double setupCost;
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
public QuoteResult(double totalPrice, String currency, PrintStats stats) {
this.totalPrice = totalPrice;
this.currency = currency;
this.stats = stats;
this.setupCost = setupCost;
}
public double getTotalPrice() {
@@ -24,8 +21,4 @@ public class QuoteResult {
public PrintStats getStats() {
return stats;
}
public double getSetupCost() {
return setupCost;
}
}

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CustomQuoteRequestAttachmentRepository extends JpaRepository<CustomQuoteRequestAttachment, UUID> {
List<CustomQuoteRequestAttachment> findByRequest_IdOrderByCreatedAtAsc(UUID requestId);
}

View File

@@ -0,0 +1,15 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FilamentVariantOrcaOverrideRepository extends JpaRepository<FilamentVariantOrcaOverride, Long> {
Optional<FilamentVariantOrcaOverride> findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(
FilamentVariant filamentVariant,
PrinterMachineProfile printerMachineProfile
);
}

View File

@@ -2,12 +2,18 @@ package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.EntityGraph;
import com.printcalculator.entity.FilamentMaterialType;
import java.util.List;
import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<FilamentVariant> findByIsActiveTrue();
// We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
}

View File

@@ -0,0 +1,20 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MaterialOrcaProfileMapRepository extends JpaRepository<MaterialOrcaProfileMap, Long> {
Optional<MaterialOrcaProfileMap> findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
PrinterMachineProfile printerMachineProfile,
FilamentMaterialType filamentMaterialType
);
@EntityGraph(attributePaths = {"filamentMaterialType"})
List<MaterialOrcaProfileMap> findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile);
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId);
boolean existsByFilamentVariant_Id(Long filamentVariantId);
}

View File

@@ -3,7 +3,11 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findAllByOrderByCreatedAtDesc();
boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId);
}

View File

@@ -0,0 +1,15 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
public interface PrinterMachineProfileRepository extends JpaRepository<PrinterMachineProfile, Long> {
Optional<PrinterMachineProfile> findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm);
Optional<PrinterMachineProfile> findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine);
List<PrinterMachineProfile> findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine);
}

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
boolean existsByFilamentVariant_Id(Long filamentVariantId);
}

View File

@@ -0,0 +1,101 @@
package com.printcalculator.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.OptionalLong;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class AdminLoginThrottleService {
private static final long BASE_DELAY_SECONDS = 2L;
private static final long MAX_DELAY_SECONDS = 3601L;
private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>();
private final boolean trustProxyHeaders;
public AdminLoginThrottleService(
@Value("${admin.auth.trust-proxy-headers:false}") boolean trustProxyHeaders
) {
this.trustProxyHeaders = trustProxyHeaders;
}
public OptionalLong getRemainingLockSeconds(String clientKey) {
LoginAttemptState state = attemptsByClient.get(clientKey);
if (state == null) {
return OptionalLong.empty();
}
long now = Instant.now().getEpochSecond();
long remaining = state.blockedUntilEpochSeconds - now;
if (remaining <= 0) {
attemptsByClient.remove(clientKey, state);
return OptionalLong.empty();
}
return OptionalLong.of(remaining);
}
public long registerFailure(String clientKey) {
long now = Instant.now().getEpochSecond();
LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> {
int nextFailures = current == null ? 1 : current.failures + 1;
long delay = calculateDelaySeconds(nextFailures);
return new LoginAttemptState(nextFailures, now + delay);
});
return calculateDelaySeconds(state.failures);
}
public void reset(String clientKey) {
attemptsByClient.remove(clientKey);
}
public String resolveClientKey(HttpServletRequest request) {
if (trustProxyHeaders) {
String forwardedFor = request.getHeader("X-Forwarded-For");
if (forwardedFor != null && !forwardedFor.isBlank()) {
String[] parts = forwardedFor.split(",");
if (parts.length > 0 && !parts[0].trim().isEmpty()) {
return parts[0].trim();
}
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
}
String remoteAddress = request.getRemoteAddr();
if (remoteAddress != null && !remoteAddress.isBlank()) {
return remoteAddress.trim();
}
return "unknown";
}
private long calculateDelaySeconds(int failures) {
long delay = BASE_DELAY_SECONDS;
for (int i = 1; i < failures; i++) {
if (delay >= MAX_DELAY_SECONDS) {
return MAX_DELAY_SECONDS;
}
delay *= 2;
}
return Math.min(delay, MAX_DELAY_SECONDS);
}
private static class LoginAttemptState {
private final int failures;
private final long blockedUntilEpochSeconds;
private LoginAttemptState(int failures, long blockedUntilEpochSeconds) {
this.failures = failures;
this.blockedUntilEpochSeconds = blockedUntilEpochSeconds;
}
}
}

View File

@@ -0,0 +1,71 @@
package com.printcalculator.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.Optional;
@Component
public class AdminSessionAuthenticationFilter extends OncePerRequestFilter {
private final AdminSessionService adminSessionService;
public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) {
this.adminSessionService = adminSessionService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = resolvePath(request);
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (!path.startsWith("/api/admin/")) {
return true;
}
return "/api/admin/auth/login".equals(path);
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional<String> token = adminSessionService.extractTokenFromCookies(request);
Optional<AdminSessionService.AdminSessionPayload> payload = token.flatMap(adminSessionService::validateSessionToken);
if (payload.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
return;
}
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated(
"admin",
null,
Collections.emptyList()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
private String resolvePath(HttpServletRequest request) {
String path = request.getRequestURI();
String contextPath = request.getContextPath();
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
return path.substring(contextPath.length());
}
return path;
}
}

View File

@@ -0,0 +1,203 @@
package com.printcalculator.security;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
import java.util.UUID;
@Service
public class AdminSessionService {
public static final String COOKIE_NAME = "admin_session";
private static final String COOKIE_PATH = "/api/admin";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private final ObjectMapper objectMapper;
private final String adminPassword;
private final byte[] sessionSecret;
private final long sessionTtlMinutes;
public AdminSessionService(
ObjectMapper objectMapper,
@Value("${admin.password}") String adminPassword,
@Value("${admin.session.secret}") String sessionSecret,
@Value("${admin.session.ttl-minutes}") long sessionTtlMinutes
) {
this.objectMapper = objectMapper;
this.adminPassword = adminPassword;
this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8);
this.sessionTtlMinutes = sessionTtlMinutes;
validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes);
}
public boolean isPasswordValid(String candidatePassword) {
if (candidatePassword == null) {
return false;
}
return MessageDigest.isEqual(
adminPassword.getBytes(StandardCharsets.UTF_8),
candidatePassword.getBytes(StandardCharsets.UTF_8)
);
}
public String createSessionToken() {
Instant now = Instant.now();
AdminSessionPayload payload = new AdminSessionPayload(
now.getEpochSecond(),
now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(),
UUID.randomUUID().toString()
);
try {
String payloadJson = objectMapper.writeValueAsString(payload);
String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlEncode(sign(encodedPayload));
return encodedPayload + "." + signature;
} catch (JsonProcessingException e) {
throw new IllegalStateException("Cannot create admin session token", e);
}
}
public Optional<AdminSessionPayload> validateSessionToken(String token) {
if (token == null || token.isBlank()) {
return Optional.empty();
}
String[] parts = token.split("\\.");
if (parts.length != 2) {
return Optional.empty();
}
String encodedPayload = parts[0];
String encodedSignature = parts[1];
byte[] providedSignature;
try {
providedSignature = base64UrlDecode(encodedSignature);
} catch (IllegalArgumentException e) {
return Optional.empty();
}
byte[] expectedSignature = sign(encodedPayload);
if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {
return Optional.empty();
}
try {
byte[] decodedPayload = base64UrlDecode(encodedPayload);
AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class);
if (payload.exp <= Instant.now().getEpochSecond()) {
return Optional.empty();
}
return Optional.of(payload);
} catch (IllegalArgumentException | IOException e) {
return Optional.empty();
}
}
public Optional<String> extractTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return Optional.empty();
}
for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName())) {
return Optional.ofNullable(cookie.getValue());
}
}
return Optional.empty();
}
public ResponseCookie buildLoginCookie(String token) {
return ResponseCookie.from(COOKIE_NAME, token)
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ofMinutes(sessionTtlMinutes))
.build();
}
public ResponseCookie buildLogoutCookie() {
return ResponseCookie.from(COOKIE_NAME, "")
.path(COOKIE_PATH)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ZERO)
.build();
}
public long getSessionTtlMinutes() {
return sessionTtlMinutes;
}
private byte[] sign(String encodedPayload) {
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM));
return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Cannot sign admin session token", e);
}
}
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
private byte[] base64UrlDecode(String data) {
return Base64.getUrlDecoder().decode(data);
}
private void validateConfiguration(String password, String secret, long ttlMinutes) {
if (password == null || password.isBlank()) {
throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty");
}
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty");
}
if (secret.length() < 32) {
throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long");
}
if (ttlMinutes <= 0) {
throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0");
}
}
public static class AdminSessionPayload {
@JsonProperty("iat")
public long iat;
@JsonProperty("exp")
public long exp;
@JsonProperty("nonce")
public String nonce;
public AdminSessionPayload() {
}
public AdminSessionPayload(long iat, long exp, String nonce) {
this.iat = iat;
this.exp = exp;
this.nonce = nonce;
}
}
}

View File

@@ -21,10 +21,12 @@ import java.nio.file.StandardCopyOption;
public class FileSystemStorageService implements StorageService {
private final Path rootLocation;
private final Path normalizedRootLocation;
private final ClamAVService clamAVService;
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
this.rootLocation = Paths.get(storageLocation);
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
this.clamAVService = clamAVService;
}
@@ -39,10 +41,7 @@ public class FileSystemStorageService implements StorageService {
@Override
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
Path destinationFile = resolveInsideStorage(destinationRelativePath);
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi
Files.createDirectories(destinationFile.getParent());
@@ -63,32 +62,46 @@ public class FileSystemStorageService implements StorageService {
@Override
public void store(Path source, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
Path destinationFile = resolveInsideStorage(destinationRelativePath);
Files.createDirectories(destinationFile.getParent());
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void delete(Path path) throws IOException {
Path file = rootLocation.resolve(path);
Path file = resolveInsideStorage(path);
Files.deleteIfExists(file);
}
@Override
public Resource loadAsResource(Path path) throws IOException {
try {
Path file = rootLocation.resolve(path);
Path file = resolveInsideStorage(path);
Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) {
return resource;
} else {
throw new RuntimeException("Could not read file: " + path);
throw new StorageException("Could not read file: " + path);
}
} catch (MalformedURLException e) {
throw new RuntimeException("Could not read file: " + path, e);
throw new StorageException("Could not read file: " + path, e);
}
}
private Path resolveInsideStorage(Path relativePath) {
if (relativePath == null) {
throw new StorageException("Path is required.");
}
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new StorageException("Cannot access absolute paths.");
}
Path resolved = normalizedRootLocation.resolve(normalizedRelative).normalize();
if (!resolved.startsWith(normalizedRootLocation)) {
throw new StorageException("Cannot access files outside storage root.");
}
return resolved;
}
}

View File

@@ -8,10 +8,17 @@ import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.stream.Collectors;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.OrderItem;
import com.printcalculator.entity.Payment;
@Service
public class InvoicePdfRenderingService {
@@ -45,4 +52,92 @@ public class InvoicePdfRenderingService {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
}
}
public byte[] generateDocumentPdf(Order order, List<OrderItem> items, boolean isConfirmation, QrBillService qrBillService, Payment payment) {
Map<String, Object> vars = new HashMap<>();
vars.put("isConfirmation", isConfirmation);
vars.put("sellerDisplayName", "3D Fab Küng Caletti");
vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank()
? order.getOrderNumber()
: order.getId().toString();
vars.put("invoiceNumber", "INV-" + displayOrderNumber.toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
// Setup Shipping Info
if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) {
String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank()
? order.getShippingCompanyName()
: order.getShippingFirstName() + " " + order.getShippingLastName();
vars.put("shippingDisplayName", shippingName);
vars.put("shippingAddressLine1", order.getShippingAddressLine1());
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
}
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto.");
String paymentMethodText = "QR / Bonifico oppure TWINT";
if (payment != null && payment.getMethod() != null) {
paymentMethodText = switch (payment.getMethod().toUpperCase()) {
case "TWINT" -> "TWINT";
case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario";
case "QR_BILL", "QR" -> "QR Bill";
case "CASH" -> "Contanti";
default -> payment.getMethod();
};
}
vars.put("paymentMethodText", paymentMethodText);
String qrBillSvg = null;
if (isConfirmation) {
qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8);
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
}
return generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
}
}

View File

@@ -0,0 +1,152 @@
package com.printcalculator.service;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.FilamentVariantOrcaOverride;
import com.printcalculator.entity.MaterialOrcaProfileMap;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.PrinterMachineProfile;
import com.printcalculator.repository.FilamentVariantOrcaOverrideRepository;
import com.printcalculator.repository.MaterialOrcaProfileMapRepository;
import com.printcalculator.repository.PrinterMachineProfileRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Optional;
@Service
public class OrcaProfileResolver {
private final PrinterMachineProfileRepository machineProfileRepo;
private final MaterialOrcaProfileMapRepository materialMapRepo;
private final FilamentVariantOrcaOverrideRepository variantOverrideRepo;
public OrcaProfileResolver(
PrinterMachineProfileRepository machineProfileRepo,
MaterialOrcaProfileMapRepository materialMapRepo,
FilamentVariantOrcaOverrideRepository variantOverrideRepo
) {
this.machineProfileRepo = machineProfileRepo;
this.materialMapRepo = materialMapRepo;
this.variantOverrideRepo = variantOverrideRepo;
}
public ResolvedProfiles resolve(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm, FilamentVariant variant) {
Optional<PrinterMachineProfile> machineProfileOpt = resolveMachineProfile(printerMachine, nozzleDiameterMm);
String machineProfileName = machineProfileOpt
.map(PrinterMachineProfile::getOrcaMachineProfileName)
.orElseGet(() -> fallbackMachineProfile(printerMachine, nozzleDiameterMm));
String filamentProfileName = machineProfileOpt
.map(machineProfile -> resolveFilamentProfileWithMachineProfile(machineProfile, variant)
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType())))
.orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType()));
return new ResolvedProfiles(machineProfileName, filamentProfileName, machineProfileOpt.orElse(null));
}
public Optional<PrinterMachineProfile> resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null) {
return Optional.empty();
}
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle != null) {
Optional<PrinterMachineProfile> exact = machineProfileRepo
.findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle);
if (exact.isPresent()) {
return exact;
}
}
Optional<PrinterMachineProfile> defaultProfile = machineProfileRepo
.findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine);
if (defaultProfile.isPresent()) {
return defaultProfile;
}
return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine)
.stream()
.findFirst();
}
private Optional<String> resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) {
if (machineProfile == null || variant == null) {
return Optional.empty();
}
Optional<FilamentVariantOrcaOverride> override = variantOverrideRepo
.findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile);
if (override.isPresent()) {
return Optional.ofNullable(override.get().getOrcaFilamentProfileName());
}
Optional<MaterialOrcaProfileMap> map = materialMapRepo
.findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue(
machineProfile,
variant.getFilamentMaterialType()
);
return map.map(MaterialOrcaProfileMap::getOrcaFilamentProfileName);
}
private String fallbackMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) {
if (machine == null || machine.getPrinterDisplayName() == null || machine.getPrinterDisplayName().isBlank()) {
return "Bambu Lab A1 0.4 nozzle";
}
String displayName = machine.getPrinterDisplayName();
if (displayName.toLowerCase().contains("bambulab a1") || displayName.toLowerCase().contains("bambu lab a1")) {
String nozzleForProfile = formatNozzleForProfileName(nozzleDiameterMm);
if (nozzleForProfile == null) {
return "Bambu Lab A1 0.4 nozzle";
}
return "Bambu Lab A1 " + nozzleForProfile + " nozzle";
}
return displayName;
}
private String fallbackFilamentProfile(FilamentMaterialType materialType) {
String materialCode = materialType != null && materialType.getMaterialCode() != null
? materialType.getMaterialCode().trim().toUpperCase()
: "PLA";
return switch (materialCode) {
case "PLA TOUGH" -> "Bambu PLA Tough @BBL A1";
case "PETG" -> "Generic PETG";
case "TPU" -> "Generic TPU";
case "PC" -> "Generic PC";
case "ABS" -> "Generic ABS";
default -> "Generic PLA";
};
}
private BigDecimal normalizeNozzle(BigDecimal nozzleDiameterMm) {
if (nozzleDiameterMm == null) {
return null;
}
return nozzleDiameterMm.setScale(2, RoundingMode.HALF_UP);
}
private String formatNozzleForProfileName(BigDecimal nozzleDiameterMm) {
BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm);
if (normalizedNozzle == null) {
return null;
}
BigDecimal stripped = normalizedNozzle.stripTrailingZeros();
if (stripped.scale() < 0) {
stripped = stripped.setScale(0);
}
return stripped.toPlainString();
}
public record ResolvedProfiles(
String machineProfileName,
String filamentProfileName,
PrinterMachineProfile machineProfile
) {}
}

View File

@@ -1,6 +1,5 @@
package com.printcalculator.service;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.entity.*;
import com.printcalculator.repository.CustomerRepository;
@@ -8,6 +7,7 @@ import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import com.printcalculator.event.OrderCreatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@@ -15,14 +15,12 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.math.RoundingMode;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class OrderService {
@@ -37,6 +35,8 @@ public class OrderService {
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService;
private final QuoteCalculator quoteCalculator;
private final PricingPolicyRepository pricingRepo;
public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
@@ -47,7 +47,9 @@ public class OrderService {
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher,
PaymentService paymentService) {
PaymentService paymentService,
QuoteCalculator quoteCalculator,
PricingPolicyRepository pricingRepo) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
@@ -58,10 +60,16 @@ public class OrderService {
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
this.quoteCalculator = quoteCalculator;
this.pricingRepo = pricingRepo;
}
@Transactional
public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) {
if (!request.isAcceptTerms() || !request.isAcceptPrivacy()) {
throw new IllegalArgumentException("Accettazione Termini e Privacy obbligatoria.");
}
QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
.orElseThrow(() -> new RuntimeException("Quote Session not found"));
@@ -81,6 +89,14 @@ public class OrderService {
customer.setPhone(request.getCustomer().getPhone());
customer.setCustomerType(request.getCustomer().getCustomerType());
if (request.getBillingAddress() != null) {
customer.setFirstName(request.getBillingAddress().getFirstName());
customer.setLastName(request.getBillingAddress().getLastName());
customer.setCompanyName(request.getBillingAddress().getCompanyName());
customer.setContactPerson(request.getBillingAddress().getContactPerson());
}
customer.setUpdatedAt(OffsetDateTime.now());
customerRepo.save(customer);
@@ -92,6 +108,7 @@ public class OrderService {
order.setStatus("PENDING_PAYMENT");
order.setCreatedAt(OffsetDateTime.now());
order.setUpdatedAt(OffsetDateTime.now());
order.setPreferredLanguage(normalizeLanguage(request.getLanguage()));
order.setCurrency("CHF");
order.setBillingCustomerType(request.getCustomer().getCustomerType());
@@ -137,24 +154,80 @@ public class OrderService {
order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
order.setShippingCostChf(BigDecimal.valueOf(9.00));
// Calculate shipping cost based on dimensions before initial save
boolean exceedsBaseSize = false;
for (QuoteLineItem item : quoteItems) {
BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO;
BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO;
BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO;
BigDecimal[] dims = {x, y, z};
java.util.Arrays.sort(dims);
if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 ||
dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 ||
dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) {
exceedsBaseSize = true;
break;
}
}
int totalQuantity = quoteItems.stream()
.mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1)
.sum();
if (exceedsBaseSize) {
order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00));
} else {
order.setShippingCostChf(BigDecimal.valueOf(2.00));
}
order = orderRepo.save(order);
List<OrderItem> savedItems = new ArrayList<>();
// Calculate global machine cost upfront
BigDecimal totalSeconds = BigDecimal.ZERO;
for (QuoteLineItem qItem : quoteItems) {
if (qItem.getPrintTimeSeconds() != null) {
totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())));
}
}
BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours);
for (QuoteLineItem qItem : quoteItems) {
OrderItem oItem = new OrderItem();
oItem.setOrder(order);
oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity());
oItem.setColorCode(qItem.getColorCode());
oItem.setMaterialCode(session.getMaterialCode());
oItem.setFilamentVariant(qItem.getFilamentVariant());
if (qItem.getFilamentVariant() != null
&& qItem.getFilamentVariant().getFilamentMaterialType() != null
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
} else {
oItem.setMaterialCode(session.getMaterialCode());
}
oItem.setUnitPriceChf(qItem.getUnitPriceChf());
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
BigDecimal distributedUnitPrice = qItem.getUnitPriceChf();
if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) {
BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity()));
BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP);
BigDecimal itemMachineCost = globalMachineCost.multiply(share);
BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP);
distributedUnitPrice = distributedUnitPrice.add(unitMachineCost);
}
oItem.setUnitPriceChf(distributedUnitPrice);
oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity())));
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
oItem.setMaterialGrams(qItem.getMaterialGrams());
oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm());
oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm());
oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm());
UUID fileUuid = UUID.randomUUID();
String ext = getExtension(qItem.getOriginalFilename());
@@ -188,9 +261,6 @@ public class OrderService {
}
order.setSubtotalChf(subtotal);
if (order.getShippingCostChf() == null) {
order.setShippingCostChf(BigDecimal.valueOf(9.00));
}
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total);
@@ -214,74 +284,13 @@ public class OrderService {
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
try {
// 1. Generate QR Bill
// 1. Generate and save the raw QR Bill for internal traceability.
byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order);
String qrBillSvg = new String(qrBillSvgBytes, StandardCharsets.UTF_8);
saveFileBytes(qrBillSvgBytes, buildQrBillSvgRelativePath(order));
// Strip XML declaration and DOCTYPE if present, as they validity break the embedding HTML page
if (qrBillSvg.contains("<?xml")) {
int svgStartIndex = qrBillSvg.indexOf("<svg");
if (svgStartIndex != -1) {
qrBillSvg = qrBillSvg.substring(svgStartIndex);
}
}
// Save QR Bill SVG
String qrRelativePath = "orders/" + order.getId() + "/documents/qr-bill.svg";
saveFileBytes(qrBillSvgBytes, qrRelativePath);
// 2. Prepare Invoice Variables
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = "BUSINESS".equals(order.getBillingCustomerType())
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Appena riceviamo il pagamento l'ordine entrerà nella coda di stampa. Grazie per la fiducia");
// 3. Generate PDF
byte[] pdfBytes = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
// Save PDF
String pdfRelativePath = "orders/" + order.getId() + "/documents/invoice-" + order.getId() + ".pdf";
saveFileBytes(pdfBytes, pdfRelativePath);
// 2. Generate and save the same confirmation PDF served by /api/orders/{id}/confirmation.
byte[] confirmationPdfBytes = invoiceService.generateDocumentPdf(order, items, true, qrBillService, null);
saveFileBytes(confirmationPdfBytes, buildConfirmationPdfRelativePath(order));
} catch (Exception e) {
e.printStackTrace();
@@ -319,4 +328,28 @@ public class OrderService {
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private String buildQrBillSvgRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/qr-bill.svg";
}
private String buildConfirmationPdfRelativePath(Order order) {
return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf";
}
private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (normalized.length() > 2) {
normalized = normalized.substring(0, 2);
}
return switch (normalized) {
case "it", "en", "de", "fr" -> normalized;
default -> "it";
};
}
}

View File

@@ -3,6 +3,7 @@ package com.printcalculator.service;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.event.PaymentConfirmedEvent;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import org.springframework.context.ApplicationEventPublisher;
@@ -38,7 +39,8 @@ public class PaymentService {
Payment payment = new Payment();
payment.setOrder(order);
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER");
// Default to "OTHER" always, as payment method should only be set by the admin explicitly
payment.setMethod("OTHER");
payment.setStatus("PENDING");
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
@@ -53,7 +55,7 @@ public class PaymentService {
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId));
.orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
if (!"PENDING".equals(payment.getStatus())) {
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
@@ -61,9 +63,10 @@ public class PaymentService {
payment.setStatus("REPORTED");
payment.setReportedAt(OffsetDateTime.now());
if (method != null && !method.isBlank()) {
payment.setMethod(method);
}
// We intentionally do not update the payment method here based on user input,
// because the user cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment);
@@ -71,4 +74,28 @@ public class PaymentService {
return payment;
}
@Transactional
public Payment confirmPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER"));
payment.setStatus("COMPLETED");
if (method != null && !method.isBlank()) {
payment.setMethod(method.toUpperCase());
}
payment.setReceivedAt(OffsetDateTime.now());
payment = paymentRepo.save(payment);
order.setStatus("IN_PRODUCTION");
order.setPaidAt(OffsetDateTime.now());
orderRepo.save(order);
eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment));
return payment;
}
}

View File

@@ -46,6 +46,7 @@ public class ProfileManager {
// Material Aliases
profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1");
profileAliases.put("pla_tough", "Bambu PLA Tough @BBL A1");
profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1");
profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1");

View File

@@ -22,7 +22,7 @@ public class QrBillService {
// Creditor (Merchant)
bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN
bill.setCreditor(createAddress(
"Küng, Joe",
"Joe Küng",
"Via G. Pioda 29a",
"6710",
"Biasca",
@@ -49,10 +49,7 @@ public class QrBillService {
bill.setAmount(order.getTotalChf());
bill.setCurrency("CHF");
// Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
bill.setUnstructuredMessage("Order " + orderRef);
bill.setUnstructuredMessage(order.getId().toString());
return bill;
}

View File

@@ -21,6 +21,8 @@ import java.util.List;
@Service
public class QuoteCalculator {
private static final BigDecimal SETUP_FEE_DOUBLE_THRESHOLD_CHF = BigDecimal.TEN;
private static final BigDecimal SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD = BigDecimal.valueOf(2);
private final PricingPolicyRepository pricingRepo;
private final PricingPolicyMachineHourTierRepository tierRepo;
@@ -60,44 +62,70 @@ public class QuoteCalculator {
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
// 3. Fetch Filament Info
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
// We try to extract material code (PLA, PETG)
String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
// Try to find specific variant (e.g. by color if we could parse it)
// For now, get default/first active variant for this material
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
return calculate(stats, machine, policy, variant);
}
// --- CALCULATIONS ---
public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) {
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) {
throw new RuntimeException("No active pricing policy found");
}
// Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
if (machine == null) {
machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
return calculate(stats, machine, policy, variant);
}
private QuoteResult calculate(PrintStats stats, PrinterMachine machine, PricingPolicy policy, FilamentVariant variant) {
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams())
.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds())
.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
// Energy Cost: (watts / 1000) * hours * costPerKwh
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts())
.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(totalHours);
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
// Subtotal (Costs + Fixed Fees)
BigDecimal fixedFee = policy.getFixedJobFeeChf();
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
BigDecimal subtotal = materialCost.add(energyCost);
BigDecimal markupFactor = BigDecimal.ONE.add(
policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)
);
subtotal = subtotal.multiply(markupFactor);
// Markup
// Markup is percentage (e.g. 20.0)
return new QuoteResult(subtotal.doubleValue(), "CHF", stats);
}
public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) {
BigDecimal rawCost = calculateMachineCost(policy, hours);
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
}
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
public BigDecimal calculateSessionSetupFee(PricingPolicy policy) {
if (policy == null || policy.getFixedJobFeeChf() == null) {
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
}
BigDecimal baseSetupFee = policy.getFixedJobFeeChf();
if (baseSetupFee.compareTo(SETUP_FEE_DOUBLE_THRESHOLD_CHF) < 0) {
return baseSetupFee
.multiply(SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD)
.setScale(2, RoundingMode.HALF_UP);
}
return baseSetupFee.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
@@ -147,6 +175,7 @@ public class QuoteCalculator {
private String detectMaterialCode(String profileName) {
String lower = profileName.toLowerCase();
if (lower.contains("pla tough") || lower.contains("pla_tough")) return "PLA TOUGH";
if (lower.contains("petg")) return "PETG";
if (lower.contains("tpu")) return "TPU";
if (lower.contains("abs")) return "ABS";

View File

@@ -2,6 +2,7 @@ package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -9,21 +10,27 @@ import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class SlicerService {
private static final Logger logger = Logger.getLogger(SlicerService.class.getName());
private static final Pattern SIZE_X_PATTERN = Pattern.compile("(?m)^\\s*size_x\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private final String slicerPath;
private final String trustedSlicerPath;
private final ProfileManager profileManager;
private final GCodeParser gCodeParser;
private final ObjectMapper mapper;
@@ -33,7 +40,7 @@ public class SlicerService {
ProfileManager profileManager,
GCodeParser gCodeParser,
ObjectMapper mapper) {
this.slicerPath = slicerPath;
this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
this.profileManager = profileManager;
this.gCodeParser = gCodeParser;
this.mapper = mapper;
@@ -76,17 +83,24 @@ public class SlicerService {
basename = basename.substring(0, basename.length() - 4);
}
Path slicerLogPath = tempDir.resolve("orcaslicer.log");
String machineProfilePath = requireSafeArgument(mFile.getAbsolutePath(), "machine profile path");
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputStlPath = requireSafeArgument(inputStl.getAbsolutePath(), "input STL path");
// 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) {
List<String> command = new ArrayList<>();
command.add(slicerPath);
// Build process arguments explicitly to avoid shell interpretation and command injection.
ProcessBuilder pb = new ProcessBuilder();
List<String> command = pb.command();
command.add(trustedSlicerPath);
command.add("--load-settings");
command.add(mFile.getAbsolutePath());
command.add(machineProfilePath);
command.add("--load-settings");
command.add(pFile.getAbsolutePath());
command.add(processProfilePath);
command.add("--load-filaments");
command.add(fFile.getAbsolutePath());
command.add(filamentProfilePath);
command.add("--ensure-on-bed");
if (useArrange) {
command.add("--arrange");
@@ -95,13 +109,12 @@ public class SlicerService {
command.add("--slice");
command.add("0");
command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString());
command.add(inputStl.getAbsolutePath());
command.add(outputDirPath);
command.add(inputStlPath);
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
Files.deleteIfExists(slicerLogPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
pb.redirectErrorStream(true);
pb.redirectOutput(slicerLogPath.toFile());
@@ -149,6 +162,89 @@ public class SlicerService {
}
}
public Optional<ModelDimensions> inspectModelDimensions(File inputModel) {
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("slicer_info_");
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
ProcessBuilder pb = new ProcessBuilder();
List<String> infoCommand = pb.command();
infoCommand.add(trustedSlicerPath);
infoCommand.add("--info");
infoCommand.add(inputModelPath);
pb.directory(tempDir.toFile());
pb.redirectErrorStream(true);
pb.redirectOutput(infoLogPath.toFile());
Process process = pb.start();
boolean finished = process.waitFor(2, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
logger.warning("Model info extraction timed out for " + inputModel.getName());
return Optional.empty();
}
String output = Files.exists(infoLogPath)
? Files.readString(infoLogPath, StandardCharsets.UTF_8)
: "";
if (process.exitValue() != 0) {
logger.warning("OrcaSlicer --info failed (exit " + process.exitValue() + ") for "
+ inputModel.getName() + ": " + output);
return Optional.empty();
}
Optional<ModelDimensions> parsed = parseModelDimensionsFromInfoOutput(output);
if (parsed.isEmpty()) {
logger.warning("Could not parse size_x/size_y/size_z from OrcaSlicer --info output for "
+ inputModel.getName() + ": " + output);
}
return parsed;
} catch (Exception e) {
logger.warning("Failed to inspect model dimensions for " + inputModel.getName() + ": " + e.getMessage());
return Optional.empty();
} finally {
if (tempDir != null) {
deleteRecursively(tempDir);
}
}
}
static Optional<ModelDimensions> parseModelDimensionsFromInfoOutput(String output) {
if (output == null || output.isBlank()) {
return Optional.empty();
}
Double x = extractDouble(SIZE_X_PATTERN, output);
Double y = extractDouble(SIZE_Y_PATTERN, output);
Double z = extractDouble(SIZE_Z_PATTERN, output);
if (x == null || y == null || z == null) {
return Optional.empty();
}
if (x <= 0 || y <= 0 || z <= 0) {
return Optional.empty();
}
return Optional.of(new ModelDimensions(x, y, z));
}
private static Double extractDouble(Pattern pattern, String text) {
Matcher matcher = pattern.matcher(text);
if (!matcher.find()) {
return null;
}
try {
return Double.parseDouble(matcher.group(1));
} catch (NumberFormatException ignored) {
return null;
}
}
private void deleteRecursively(Path path) {
if (path == null || !Files.exists(path)) {
return;
@@ -177,4 +273,38 @@ public class SlicerService {
|| normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles");
}
private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required");
}
if (containsControlChars(configuredPath)) {
throw new IllegalArgumentException("slicer.path contains invalid control characters");
}
try {
return Path.of(configuredPath.trim()).normalize().toString();
} catch (InvalidPathException e) {
throw new IllegalArgumentException("Invalid slicer.path: " + configuredPath, e);
}
}
private String requireSafeArgument(String value, String argName) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Missing required argument: " + argName);
}
if (containsControlChars(value)) {
throw new IOException("Invalid control characters in " + argName);
}
return value;
}
private boolean containsControlChars(String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\0' || ch == '\n' || ch == '\r') {
return true;
}
}
return false;
}
}

View File

@@ -15,20 +15,47 @@ public class TwintPaymentService {
private final String twintPaymentUrl;
public TwintPaymentService(
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}")
@Value("${payment.twint.url:}")
String twintPaymentUrl
) {
this.twintPaymentUrl = twintPaymentUrl;
}
public String getTwintPaymentUrl() {
return twintPaymentUrl;
public String getTwintPaymentUrl(com.printcalculator.entity.Order order) {
if (twintPaymentUrl == null || twintPaymentUrl.isBlank()) {
throw new IllegalStateException("TWINT_PAYMENT_URL is not configured");
}
StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl);
if (order != null) {
if (order.getTotalChf() != null) {
urlBuilder.append("&amount=").append(order.getTotalChf().toPlainString());
}
String orderNumber = order.getOrderNumber();
if (orderNumber == null && order.getId() != null) {
orderNumber = order.getId().toString();
}
if (orderNumber != null) {
try {
urlBuilder.append("&trxInfo=").append(order.getId());
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
return urlBuilder.toString();
}
public byte[] generateQrPng(int sizePx) {
public byte[] generateQrPng(com.printcalculator.entity.Order order, int sizePx) {
try {
String url = getTwintPaymentUrl(order);
// Use High Error Correction for financial QR codes
QrCode qrCode = QrCode.encodeText(twintPaymentUrl, QrCode.Ecc.HIGH);
QrCode qrCode = QrCode.encodeText(url, QrCode.Ecc.HIGH);
// Standard QR quiet zone is 4 modules
int borderModules = 4;

View File

@@ -14,4 +14,16 @@ public interface EmailNotificationService {
*/
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
/**
* Sends an HTML email using a Thymeleaf template, with an optional attachment.
*
* @param to The recipient email address.
* @param subject The subject of the email.
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
* @param contextData The data to populate the template with.
* @param attachmentName The name for the attachment file.
* @param attachmentData The raw bytes of the attachment.
*/
void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData);
}

View File

@@ -10,6 +10,7 @@ import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.springframework.core.io.ByteArrayResource;
import java.util.Map;
@@ -29,6 +30,11 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
sendEmailWithAttachment(to, subject, templateName, contextData, null, null);
}
@Override
public void sendEmailWithAttachment(String to, String subject, String templateName, Map<String, Object> contextData, String attachmentName, byte[] attachmentData) {
if (!mailEnabled) {
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
return;
@@ -49,6 +55,10 @@ public class SmtpEmailNotificationService implements EmailNotificationService {
helper.setSubject(subject);
helper.setText(process, true); // true indicates HTML format
if (attachmentName != null && attachmentData != null) {
helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData));
}
emailSender.send(mimeMessage);
log.info("Email successfully sent to {}", to);

View File

@@ -1,2 +1,8 @@
app.mail.enabled=false
app.mail.admin.enabled=false
app.mail.contact-request.admin.enabled=false
# Admin back-office local test credentials
admin.password=local-admin-password
admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
admin.session.ttl-minutes=480

View File

@@ -4,9 +4,10 @@ server.port=8000
# Database Configuration
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
spring.datasource.username=${DB_USERNAME:printcalc}
spring.datasource.password=${DB_PASSWORD:printcalc_secret}
spring.datasource.password=${DB_PASSWORD:}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false
# Slicer Configuration
@@ -25,13 +26,13 @@ clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
payment.twint.url=${TWINT_PAYMENT_URL:}
# Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch}
spring.mail.password=${MAIL_PASSWORD:ht*44k+Tq39R+R-O}
spring.mail.password=${MAIL_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
@@ -40,4 +41,15 @@ app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true}
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
# Admin back-office authentication
admin.password=${ADMIN_PASSWORD}
admin.session.secret=${ADMIN_SESSION_SECRET}
admin.session.ttl-minutes=${ADMIN_SESSION_TTL_MINUTES:480}
admin.auth.trust-proxy-headers=${ADMIN_AUTH_TRUST_PROXY_HEADERS:false}
# Expose only liveness endpoint by default.
management.endpoints.web.exposure.include=health

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Nuova richiesta di contatto</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 640px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-top: 0;
color: #222222;
}
p {
color: #444444;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eeeeee;
padding: 10px 6px;
color: #333333;
word-break: break-word;
}
th {
width: 35%;
color: #222222;
background: #fafafa;
}
.footer {
margin-top: 24px;
font-size: 12px;
color: #888888;
border-top: 1px solid #eeeeee;
padding-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1>Nuova richiesta di contatto</h1>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table>
<tr>
<th>ID richiesta</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th>Data</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th>Tipo richiesta</th>
<td th:text="${requestType}">PRINT_SERVICE</td>
</tr>
<tr>
<th>Tipo cliente</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th>Nome</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th>Azienda</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th>Contatto</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th>Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th>Telefono</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th>Messaggio</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th>Allegati</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
</div>
</div>
</body>
</html>

View File

@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Conferma Ordine</title>
<title th:text="${emailTitle}">Order Confirmation</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -10,6 +10,7 @@
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
@@ -18,30 +19,41 @@
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.order-details {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.order-details th {
text-align: left;
padding-right: 20px;
color: #333333;
vertical-align: top;
}
.order-details td {
word-break: break-word;
}
.footer {
text-align: center;
font-size: 0.9em;
@@ -53,41 +65,46 @@
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Grazie per il tuo ordine #<span th:text="${orderNumber}">00000000</span></h1>
</div>
<div class="content">
<p>Ciao <span th:text="${customerName}">Cliente</span>,</p>
<p>Abbiamo ricevuto il tuo ordine e stiamo iniziando a elaborarlo. Ecco un riepilogo dei dettagli:</p>
<div class="order-details">
<table>
<tr>
<th>Numero Ordine:</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th>Data:</th>
<td th:text="${orderDate}">01/01/2026</td>
</tr>
<tr>
<th>Costo totale:</th>
<td th:text="${totalCost} + ' CHF'">0.00 CHF</td>
</tr>
</table>
</div>
<p>
Clicca qui per i dettagli:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://tuosito.it/ordine/00000000-0000-0000-0000-000000000000</a>
</p>
<p>Se hai domande o dubbi, non esitare a contattarci.</p>
</div>
<div class="footer">
<p>&copy; 2026 3D-Fab. Tutti i diritti riservati.</p>
</div>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">We received your order and started processing it.</p>
<div class="order-details">
<p style="margin-top:0; font-weight: bold;" th:text="${detailsTitleText}">Order details</p>
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelDate}">Date</th>
<td th:text="${orderDate}">Jan 1, 2026, 10:00:00 AM</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p>
<span th:text="${orderDetailsCtaText}">View order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${attachmentHintText}">The order confirmation PDF is attached.</p>
<p th:text="${supportText}">If you have questions, reply to this email.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Payment Confirmed</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.status-box {
background-color: #e8f8ee;
border: 1px solid #9fd4af;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box {
background-color: #f9f9f9;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box th {
text-align: left;
padding-right: 18px;
vertical-align: top;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">Your payment has been confirmed and your order is now in production.</p>
<div class="status-box">
<strong th:text="${statusText}">Current status: In production.</strong>
</div>
<div class="order-box">
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p th:text="${attachmentHintText}">The paid invoice PDF is attached to this email.</p>
<p>
<span th:text="${orderDetailsCtaText}">View order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${supportText}">We will notify you when shipment is ready.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Payment Reported</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.status-box {
background-color: #fff9e8;
border: 1px solid #f4d68a;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box {
background-color: #f9f9f9;
border-radius: 5px;
padding: 12px;
margin-top: 18px;
}
.order-box th {
text-align: left;
padding-right: 18px;
vertical-align: top;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
</div>
<div class="content">
<p th:text="${greetingText}">Hi Customer,</p>
<p th:text="${introText}">We received your payment report and we are now verifying it.</p>
<div class="status-box">
<strong th:text="${statusText}">Current status: Payment under verification.</strong>
</div>
<div class="order-box">
<table>
<tr>
<th th:text="${labelOrderNumber}">Order number</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th th:text="${labelTotal}">Total</th>
<td th:text="${totalCost}">CHF 0.00</td>
</tr>
</table>
</div>
<p>
<span th:text="${orderDetailsCtaText}">Check order status</span>:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://example.com/en/co/00000000-0000-0000-0000-000000000000</a>
</p>
<p th:text="${supportText}">You will receive another email once payment is confirmed.</p>
</div>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab</p>
<p th:text="${footerText}">Automated message.</p>
</div>
</div>
</body>
</html>

View File

@@ -1,358 +1,412 @@
<!DOCTYPE html>
<html lang="it" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<style>
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
@page qrpage { size: A4; margin: 0; }
<meta charset="utf-8"/>
<style>
@page invoice {
size: A4;
margin: 12mm 12mm 12mm 12mm;
}
body {
page: invoice;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
margin: 0;
padding: 0;
background: #fff;
color: #191919;
line-height: 1.35;
}
@page qrpage {
size: A4;
margin: 0;
}
.invoice-page {
page: invoice;
width: 100%;
}
*, *:before, *:after {
box-sizing: border-box;
}
.top-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
body {
page: invoice;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 8.5pt;
margin: 0;
padding: 0;
background: #fff;
color: #000;
line-height: 1.35;
}
.top-layout td {
vertical-align: top;
padding: 0;
}
.invoice-page {
page: invoice;
width: 100%;
page-break-after: always;
}
.doc-title {
font-size: 18pt;
font-weight: 700;
margin: 0 0 1.5mm 0;
letter-spacing: 0.2px;
}
/* Top Header Layout */
.header-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 25mm;
}
.doc-subtitle {
color: #4b4b4b;
font-size: 10pt;
}
.header-layout td {
vertical-align: top;
padding: 0;
}
.seller-block {
text-align: right;
line-height: 1.45;
width: 42%;
}
.logo-block {
width: 33%;
font-size: 24pt;
font-weight: bold;
letter-spacing: -0.5px;
}
.seller-name {
font-size: 11pt;
font-weight: 700;
}
.logo-3d {
color: #111827; /* Dark black/blue */
}
.meta-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
.logo-fab {
color: #eab308; /* Yellow/Gold */
}
.meta-layout td {
vertical-align: top;
padding: 0;
}
.seller-block {
width: 33%;
font-size: 9pt;
line-height: 1.4;
}
.order-details {
width: 60%;
padding-right: 5mm;
}
.website-block {
width: 33%;
text-align: right;
font-size: 9pt;
}
.customer-box {
width: 40%;
background: #f7f7f7;
border: 1px solid #e2e2e2;
padding: 3mm 3.2mm;
}
/* Document Title */
.doc-title {
font-size: 20pt;
font-weight: normal;
margin: 0 0 10mm 0;
letter-spacing: -0.5px;
}
.box-title {
font-size: 8.8pt;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #5a5a5a;
margin-bottom: 2mm;
font-weight: 700;
}
/* Meta and Customer Details Layout */
.details-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-bottom: 15mm;
}
.details-table {
width: 100%;
border-collapse: collapse;
}
.details-layout td {
vertical-align: top;
padding: 0;
}
.details-table td {
padding: 1.1mm 0;
border-bottom: 1px solid #ececec;
vertical-align: top;
}
.meta-container {
width: 50%;
}
.details-label {
color: #636363;
width: 56%;
white-space: nowrap;
padding-right: 3mm;
}
.customer-container {
width: 50%;
font-size: 10pt;
line-height: 1.5;
}
.details-value {
text-align: left;
font-weight: 600;
}
.meta-table {
border-collapse: collapse;
font-size: 8.5pt;
}
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 3mm;
border-top: 1px solid #cfcfcf;
}
.meta-table td {
padding: 1.5mm 0;
vertical-align: top;
}
.line-items th,
.line-items td {
border-bottom: 1px solid #dedede;
padding: 2.4mm 2mm;
vertical-align: top;
word-wrap: break-word;
}
.meta-label {
width: 45mm;
padding-right: 2mm;
}
.line-items th {
text-align: left;
font-weight: 700;
background: #f2f2f2;
color: #2c2c2c;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.25px;
}
.meta-value {
/* allow wrapping just in case */
}
.line-items th:nth-child(1),
.line-items td:nth-child(1) {
width: 50%;
}
/* Line Items Table */
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 5mm;
font-size: 8.5pt;
}
.line-items th:nth-child(2),
.line-items td:nth-child(2) {
width: 10%;
text-align: right;
white-space: nowrap;
}
.line-items th,
.line-items td {
padding: 1.5mm 0;
vertical-align: top;
}
.line-items th:nth-child(3),
.line-items td:nth-child(3) {
width: 20%;
text-align: right;
white-space: nowrap;
}
.line-items th {
text-align: left;
font-weight: normal;
border-bottom: 1pt solid #000;
}
.line-items th:nth-child(4),
.line-items td:nth-child(4) {
width: 20%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.line-items tbody td {
border-bottom: 0.5pt solid #e0e0e0;
}
.summary-layout {
width: 100%;
border-collapse: collapse;
margin-top: 6mm;
}
.line-items tbody tr:last-child td {
border-bottom: 1pt solid #000;
}
.summary-layout td {
vertical-align: top;
padding: 0;
}
.line-items th.center,
.line-items td.center {
text-align: center;
}
.notes {
width: 58%;
padding-right: 5mm;
color: #383838;
line-height: 1.45;
}
.line-items th.right,
.line-items td.right {
text-align: right;
}
.notes .section-caption {
font-weight: 700;
margin: 0 0 1.2mm 0;
color: #2a2a2a;
}
.col-desc { width: 45%; }
.col-qty { width: 10%; }
.col-price { width: 22%; }
.col-total { width: 23%; }
.totals {
width: 42%;
margin-left: auto;
border-collapse: collapse;
}
.item-desc {
padding-right: 4mm;
}
.totals td {
border: none;
padding: 1.3mm 0;
}
/* Totals Block */
.totals-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 0;
font-size: 8.5pt;
}
.totals-label {
text-align: left;
color: #4a4a4a;
}
.totals-table td {
padding: 1.5mm 0;
border-bottom: 0.5pt solid #000;
}
.totals-value {
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.totals-label {
text-align: left;
}
.total-strong td {
font-size: 10.5pt;
font-weight: 700;
padding-top: 2mm;
border-top: 1px solid #cfcfcf;
}
.totals-value {
text-align: right;
width: 30%;
}
.due-row td {
font-size: 10pt;
font-weight: 700;
border-top: 1px solid #cfcfcf;
padding-top: 2.2mm;
}
.totals-table tr.no-border td {
border-bottom: none;
}
.qr-only-page {
page: qrpage;
position: relative;
width: 210mm;
height: 297mm;
background: #fff;
page-break-before: always;
}
.summary-notes {
margin-top: 4mm;
padding-bottom: 4mm;
border-bottom: 1pt solid #000;
}
.qr-bill-bottom {
position: absolute;
left: 0;
bottom: 0;
width: 210mm;
height: 105mm;
overflow: hidden;
background: #fff;
}
/* Footer Notes Layout */
.footer-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 15mm;
font-size: 8.5pt;
}
.qr-bill-bottom svg {
width: 210mm !important;
height: 105mm !important;
display: block;
}
</style>
.footer-layout td {
vertical-align: top;
padding: 0 0 3mm 0;
}
.footer-label {
width: 25%;
font-weight: normal;
}
.footer-text {
width: 75%;
line-height: 1.4;
}
/* QR Page */
.qr-only-page {
page: qrpage;
width: 100%;
height: 297mm;
background: #fff;
page-break-inside: avoid;
}
.qr-only-layout {
width: 100%;
height: 297mm;
border-collapse: collapse;
table-layout: fixed;
}
.qr-only-layout td {
vertical-align: bottom;
/* Keep the QR slip at page bottom, but with a safer print margin. */
padding: 0 0 1mm 0;
}
.qr-bill-bottom {
width: 100%;
height: 105mm;
overflow: hidden;
background: #fff;
}
.qr-bill-bottom svg {
width: 100% !important;
height: 105mm !important;
display: block;
}
</style>
</head>
<body>
<div class="invoice-page">
<table class="top-layout">
<tr>
<td>
<div class="doc-title">Conferma ordine</div>
<div class="doc-subtitle">Ricevuta semplificata</div>
</td>
<td class="seller-block">
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
<div th:text="${sellerEmail}">info@3dfab.ch</div>
</td>
</tr>
</table>
<!-- Header -->
<table class="header-layout">
<tr>
<td class="logo-block">
<span class="logo-3d">3D</span> <span class="logo-fab">fab</span>
<div style="font-size: 14pt; font-weight: normal; margin-top: 4px; color: #111827;">Küng Caletti</div>
</td>
<td class="seller-block">
<div th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Via G. pioda 29a - 6710 Biasca, Svizzera</div>
<div th:text="${sellerAddressLine2}">Lyss-Strasse 71 - 2560 Nidau, Svizzera</div>
</td>
<td class="website-block">
www.3d-fab.ch
</td>
</tr>
</table>
<table class="meta-layout">
<tr>
<td class="order-details">
<table class="details-table">
<tr>
<td class="details-label">Data ordine / fattura</td>
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
</tr>
<tr>
<td class="details-label">Numero documento</td>
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr>
<tr>
<td class="details-label">Data di scadenza</td>
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
</tr>
<tr>
<td class="details-label">Valuta</td>
<td class="details-value">CHF</td>
</tr>
</table>
</td>
<td class="customer-box">
<div class="box-title">Cliente</div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</td>
</tr>
</table>
<!-- Document Title -->
<div class="doc-title">
<span th:if="${isConfirmation}">Conferma dell'ordine</span>
<span th:unless="${isConfirmation}">Fattura</span>
<span th:text="${invoiceNumber}">141052743</span>
<span th:unless="${isConfirmation}" style="color: #2e7d32; font-weight: bold; font-size: 18pt; padding-left: 15px;">PAGATO</span>
</div>
<table class="line-items">
<thead>
<tr>
<th>Descrizione</th>
<th>Qtà</th>
<th>Prezzo unit.</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td th:text="${lineItem.quantity}">1</td>
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr>
</tbody>
</table>
<table class="summary-layout">
<tr>
<td class="notes">
<div class="section-caption">Informazioni</div>
<div th:text="${paymentTermsText}">
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</div>
<div style="margin-top: 2.5mm;">
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</div>
</td>
<td>
<table class="totals">
<!-- Details block (Meta and Customer) -->
<table class="details-layout">
<tr>
<td class="totals-label">Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
<td class="meta-container">
<table class="meta-table">
<tr>
<td class="meta-label">Data dell'ordine / fattura</td>
<td class="meta-value" th:text="${invoiceDate}">07.03.2025</td>
</tr>
<tr>
<td class="meta-label">Numero documento</td>
<td class="meta-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr>
<tr>
<td class="meta-label">Data di scadenza</td>
<td class="meta-value" th:text="${dueDate}">07.03.2025</td>
</tr>
<tr>
<td class="meta-label">Metodo di pagamento</td>
<td class="meta-value" th:text="${paymentMethodText}">QR / Bonifico oppure TWINT</td>
</tr>
<tr>
<td class="meta-label">Valuta</td>
<td class="meta-value">CHF</td>
</tr>
</table>
</td>
<td class="customer-container">
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di fatturazione:</div>
<div th:text="${buyerDisplayName}">Joe Küng</div>
<div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
<div th:text="${buyerAddressLine2}">6710 biasca</div>
<div>Svizzera</div>
<br/>
<div th:if="${shippingDisplayName != null}">
<div style="font-weight: bold; margin-bottom: 2mm;">Indirizzo di spedizione:</div>
<div th:text="${shippingDisplayName}">Joe Küng</div>
<div th:text="${shippingAddressLine1}">Via G.Pioda, 29a</div>
<div th:text="${shippingAddressLine2}">6710 biasca</div>
<div>Svizzera</div>
</div>
</td>
</tr>
<tr class="total-strong">
<td class="totals-label">Totale ordine</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</table>
<!-- Items Table -->
<table class="line-items">
<thead>
<tr>
<th class="col-desc">Descrizione</th>
<th class="col-qty center">Quantità</th>
<th class="col-price right">Prezzo unitario</th>
<th class="col-total right">Prezzo incl.</th>
</tr>
<tr class="due-row">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td class="item-desc" th:text="${lineItem.description}">Apple iPhone 16 Pro</td>
<td class="center" th:text="${lineItem.quantity}">1</td>
<td class="right" th:text="${lineItem.unitPriceFormatted}">968.55</td>
<td class="right" th:text="${lineItem.lineTotalFormatted}">1'047.00</td>
</tr>
</table>
</td>
</tr>
</table>
</tbody>
</table>
<!-- Totals -->
<table class="totals-table">
<tr>
<td class="totals-label">Importo totale</td>
<td class="totals-value" th:text="${subtotalFormatted}">1'012.86</td>
</tr>
<tr>
<td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td>
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
</tr>
<tr class="no-border" th:if="${isConfirmation}">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
</tr>
<tr class="no-border" th:unless="${isConfirmation}">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value">CHF 0.00</td>
</tr>
</table>
<!-- Footer Notes -->
<table class="footer-layout">
<tr>
<td class="footer-label">Informazioni</td>
<td class="footer-text" th:text="${paymentTermsText}">
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</td>
</tr>
<tr>
<td class="footer-label">Generale</td>
<td class="footer-text">
Si applicano le nostre condizioni generali di contratto. Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</td>
</tr>
</table>
</div>
<div class="qr-only-page">
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
<!-- QR Bill Page (only renders if QR data is passed) -->
<div class="qr-only-page" th:if="${qrBillSvg != null}">
<table class="qr-only-layout">
<tr>
<td>
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
</td>
</tr>
</table>
</div>
</body>

View File

@@ -0,0 +1,134 @@
package com.printcalculator.controller;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.controller.admin.AdminAuthController;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
@WebMvcTest(controllers = AdminAuthController.class)
@Import({
SecurityConfig.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class
})
@TestPropertySource(properties = {
"admin.password=test-admin-password",
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"admin.session.ttl-minutes=60"
})
class AdminAuthSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void loginOk_ShouldReturnCookie() throws Exception {
MvcResult result = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.1");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authenticated").value(true))
.andReturn();
String setCookie = result.getResponse().getHeader(HttpHeaders.SET_COOKIE);
assertNotNull(setCookie);
assertTrue(setCookie.contains("admin_session="));
assertTrue(setCookie.contains("HttpOnly"));
assertTrue(setCookie.contains("Secure"));
assertTrue(setCookie.contains("SameSite=Strict"));
assertTrue(setCookie.contains("Path=/api/admin"));
}
@Test
void loginKo_ShouldReturnUnauthorized() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.2");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.authenticated").value(false))
.andExpect(jsonPath("$.retryAfterSeconds").value(2));
}
@Test
void loginKoSecondAttemptDuringLock_ShouldReturnTooManyRequests() throws Exception {
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.3");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.retryAfterSeconds").value(2));
mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.3");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"wrong-password\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.authenticated").value(false));
}
@Test
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
mockMvc.perform(get("/api/admin/auth/me"))
.andExpect(status().isUnauthorized());
}
@Test
void adminAccessWithValidCookie_ShouldReturn200() throws Exception {
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.4");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
.andReturn();
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
assertNotNull(setCookie);
Cookie adminCookie = toCookie(setCookie);
mockMvc.perform(get("/api/admin/auth/me").cookie(adminCookie))
.andExpect(status().isOk())
.andExpect(jsonPath("$.authenticated").value(true));
}
private Cookie toCookie(String setCookieHeader) {
String[] parts = setCookieHeader.split(";", 2);
String[] keyValue = parts[0].split("=", 2);
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
}
}

View File

@@ -0,0 +1,107 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminOrderStatusUpdateRequest;
import com.printcalculator.dto.OrderDto;
import com.printcalculator.entity.Order;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.PaymentService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminOrderControllerStatusValidationTest {
@Mock
private OrderRepository orderRepository;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private PaymentRepository paymentRepository;
@Mock
private PaymentService paymentService;
@Mock
private StorageService storageService;
@Mock
private InvoicePdfRenderingService invoicePdfRenderingService;
@Mock
private QrBillService qrBillService;
private AdminOrderController controller;
@BeforeEach
void setUp() {
controller = new AdminOrderController(
orderRepository,
orderItemRepository,
paymentRepository,
paymentService,
storageService,
invoicePdfRenderingService,
qrBillService
);
}
@Test
void updateOrderStatus_withInvalidStatus_shouldReturn400AndNotSave() {
UUID orderId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setStatus("PENDING_PAYMENT");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest();
payload.setStatus("REPORTED");
ResponseStatusException ex = assertThrows(
ResponseStatusException.class,
() -> controller.updateOrderStatus(orderId, payload)
);
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
verify(orderRepository, never()).save(any(Order.class));
}
@Test
void updateOrderStatus_withValidStatus_shouldReturn200() {
UUID orderId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setStatus("PENDING_PAYMENT");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of());
when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty());
AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest();
payload.setStatus("PAID");
ResponseEntity<OrderDto> response = controller.updateOrderStatus(orderId, payload);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("PAID", response.getBody().getStatus());
verify(orderRepository).save(order);
}
}

View File

@@ -3,6 +3,10 @@ package com.printcalculator.event.listener;
import com.printcalculator.entity.Customer;
import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService;
import com.printcalculator.service.email.EmailNotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -12,15 +16,23 @@ import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -30,17 +42,32 @@ class OrderEmailListenerTest {
@Mock
private EmailNotificationService emailNotificationService;
@Mock
private InvoicePdfRenderingService invoicePdfRenderingService;
@Mock
private OrderItemRepository orderItemRepository;
@Mock
private QrBillService qrBillService;
@Mock
private StorageService storageService;
@InjectMocks
private OrderEmailListener orderEmailListener;
@Captor
private ArgumentCaptor<Map<String, Object>> templateDataCaptor;
@Captor
private ArgumentCaptor<byte[]> attachmentDataCaptor;
private Order order;
private OrderCreatedEvent event;
@BeforeEach
void setUp() {
void setUp() throws Exception {
Customer customer = new Customer();
customer.setFirstName("John");
customer.setLastName("Doe");
@@ -56,55 +83,59 @@ class OrderEmailListenerTest {
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://3d-fab.ch");
when(storageService.loadAsResource(any())).thenReturn(new ByteArrayResource("PDF".getBytes(StandardCharsets.UTF_8)));
}
@Test
void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() {
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert Customer Email
verify(emailNotificationService, times(1)).sendEmail(
verify(emailNotificationService, times(1)).sendEmailWithAttachment(
eq("john.doe@test.com"),
eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"),
eq("order-confirmation"),
templateDataCaptor.capture()
templateDataCaptor.capture(),
eq("Conferma-Ordine-" + order.getOrderNumber() + ".pdf"),
attachmentDataCaptor.capture()
);
Map<String, Object> customerData = templateDataCaptor.getAllValues().get(0);
Map<String, Object> customerData = templateDataCaptor.getValue();
assertEquals("John", customerData.get("customerName"));
assertEquals(order.getId(), customerData.get("orderId"));
assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl"));
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
assertEquals("150.50", customerData.get("totalCost"));
assertEquals("https://3d-fab.ch/it/co/" + order.getId(), customerData.get("orderDetailsUrl"));
assertNotNull(customerData.get("orderDate"));
assertTrue(customerData.get("orderDate").toString().contains("2026"));
assertTrue(customerData.get("totalCost").toString().contains("150"));
assertArrayEquals("PDF".getBytes(StandardCharsets.UTF_8), attachmentDataCaptor.getValue());
// Assert Admin Email
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> adminTemplateCaptor = (ArgumentCaptor<Map<String, Object>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Map.class);
verify(emailNotificationService, times(1)).sendEmail(
eq("admin@printcalculator.local"),
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - Doe"),
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - John Doe"),
eq("order-confirmation"),
templateDataCaptor.capture()
adminTemplateCaptor.capture()
);
Map<String, Object> adminData = templateDataCaptor.getAllValues().get(1);
Map<String, Object> adminData = adminTemplateCaptor.getValue();
assertEquals("John Doe", adminData.get("customerName"));
}
@Test
void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() {
// Arrange
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false);
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert
verify(emailNotificationService, times(1)).sendEmail(
verify(emailNotificationService, times(1)).sendEmailWithAttachment(
eq("john.doe@test.com"),
anyString(),
anyString(),
anyMap(),
anyString(),
any()
);
@@ -112,20 +143,18 @@ class OrderEmailListenerTest {
eq("admin@printcalculator.local"),
anyString(),
anyString(),
any()
anyMap()
);
}
@Test
void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() {
// Arrange
doThrow(new RuntimeException("Simulated Mail Failure"))
.when(emailNotificationService).sendEmail(anyString(), anyString(), anyString(), any());
.when(emailNotificationService).sendEmailWithAttachment(anyString(), anyString(), anyString(), anyMap(), anyString(), any());
// Act & Assert
// Event listener shouldn't throw exception back, thus passing the test.
orderEmailListener.handleOrderCreatedEvent(event);
assertDoesNotThrow(() -> orderEmailListener.handleOrderCreatedEvent(event));
verify(emailNotificationService, times(1)).sendEmail(anyString(), anyString(), anyString(), any());
verify(emailNotificationService, times(1))
.sendEmailWithAttachment(anyString(), anyString(), anyString(), anyMap(), anyString(), any());
}
}

View File

@@ -0,0 +1,40 @@
package com.printcalculator.security;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class AdminLoginThrottleServiceTest {
private final AdminLoginThrottleService service = new AdminLoginThrottleService(false);
@Test
void registerFailure_ShouldDoubleDelay() {
assertEquals(2L, service.registerFailure("127.0.0.1"));
assertEquals(4L, service.registerFailure("127.0.0.1"));
assertEquals(8L, service.registerFailure("127.0.0.1"));
}
@Test
void resolveClientKey_ShouldUseRemoteAddress_WhenProxyHeadersAreNotTrusted() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.10");
when(request.getHeader("X-Real-IP")).thenReturn("203.0.113.11");
when(request.getRemoteAddr()).thenReturn("10.0.0.5");
assertEquals("10.0.0.5", service.resolveClientKey(request));
}
@Test
void resolveClientKey_ShouldUseForwardedFor_WhenProxyHeadersAreTrusted() {
AdminLoginThrottleService trustedService = new AdminLoginThrottleService(true);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.10, 10.0.0.5");
when(request.getRemoteAddr()).thenReturn("10.0.0.5");
assertEquals("203.0.113.10", trustedService.resolveClientKey(request));
}
}

View File

@@ -0,0 +1,61 @@
package com.printcalculator.service;
import com.printcalculator.model.ModelDimensions;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class SlicerServiceTest {
@Test
void parseModelDimensionsFromInfoOutput_validOutput_returnsDimensions() {
String output = """
[file.stl]
size_x = 130.860428
size_y = 225.000000
size_z = 140.000000
min_x = 0.000000
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isPresent());
assertEquals(130.860428, dimensions.get().xMm(), 0.000001);
assertEquals(225.0, dimensions.get().yMm(), 0.000001);
assertEquals(140.0, dimensions.get().zMm(), 0.000001);
}
@Test
void parseModelDimensionsFromInfoOutput_withNoise_returnsDimensions() {
String output = """
[2026-02-27 10:26:30.306251] [0x1] [trace] Initializing StaticPrintConfigs
[model.3mf]
size_x = 97.909241
size_y = 97.909241
size_z = 70.000008
[2026-02-27 10:26:30.314575] [0x1] [error] calc_exclude_triangles
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isPresent());
assertEquals(97.909241, dimensions.get().xMm(), 0.000001);
assertEquals(97.909241, dimensions.get().yMm(), 0.000001);
assertEquals(70.000008, dimensions.get().zMm(), 0.000001);
}
@Test
void parseModelDimensionsFromInfoOutput_missingValues_returnsEmpty() {
String output = """
[model.step]
size_x = 10.0
size_y = 20.0
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isEmpty());
}
}

246
db.sql
View File

@@ -44,6 +44,10 @@ create table filament_variant
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc.
color_hex text,
finish_type text not null default 'GLOSSY'
check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')),
brand text,
is_matte boolean not null default false,
is_special boolean not null default false,
@@ -59,7 +63,6 @@ create table filament_variant
unique (filament_material_type_id, variant_display_name)
);
-- (opzionale) kg disponibili calcolati
create view filament_variant_stock_kg as
select filament_variant_id,
stock_spools,
@@ -67,6 +70,37 @@ select filament_variant_id,
(stock_spools * spool_net_kg) as stock_kg
from filament_variant;
create table printer_machine_profile
(
printer_machine_profile_id bigserial primary key,
printer_machine_id bigint not null references printer_machine (printer_machine_id) on delete cascade,
nozzle_diameter_mm numeric(4, 2) not null check (nozzle_diameter_mm > 0),
orca_machine_profile_name text not null,
is_default boolean not null default false,
is_active boolean not null default true,
unique (printer_machine_id, nozzle_diameter_mm)
);
create table material_orca_profile_map
(
material_orca_profile_map_id bigserial primary key,
printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade,
filament_material_type_id bigint not null references filament_material_type (filament_material_type_id),
orca_filament_profile_name text not null,
is_active boolean not null default true,
unique (printer_machine_profile_id, filament_material_type_id)
);
create table filament_variant_orca_override
(
filament_variant_orca_override_id bigserial primary key,
filament_variant_id bigint not null references filament_variant (filament_variant_id) on delete cascade,
printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade,
orca_filament_profile_name text not null,
is_active boolean not null default true,
unique (filament_variant_id, printer_machine_profile_id)
);
create table pricing_policy
@@ -251,8 +285,10 @@ insert into filament_material_type (material_code,
is_technical,
technical_type_label)
values ('PLA', false, false, null),
('PLA TOUGH', false, false, null),
('PETG', false, false, null),
('TPU', true, false, null),
('PC', false, true, 'engineering'),
('ABS', false, false, null),
('Nylon', false, false, null),
('Carbon PLA', false, false, null)
@@ -276,6 +312,9 @@ insert
into filament_variant (filament_material_type_id,
variant_display_name,
color_name,
color_hex,
finish_type,
brand,
is_matte,
is_special,
cost_chf_per_kg,
@@ -285,6 +324,9 @@ into filament_variant (filament_material_type_id,
select pla.filament_material_type_id,
v.variant_display_name,
v.color_name,
v.color_hex,
v.finish_type,
null::text as brand,
v.is_matte,
v.is_special,
18.00, -- PLA da Excel
@@ -292,17 +334,145 @@ select pla.filament_material_type_id,
1.000,
true
from pla
cross join (values ('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
('PLA Nero', 'Nero', false, false, 3.000::numeric),
('PLA Blu', 'Blu', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric),
('PLA Viola', 'Viola', false, false,
1.000::numeric)) as v(variant_display_name, color_name, is_matte, is_special, stock_spools)
cross join (values ('PLA Bianco', 'Bianco', '#F5F5F5', 'GLOSSY', false, false, 3.000::numeric),
('PLA Nero', 'Nero', '#1A1A1A', 'GLOSSY', false, false, 3.000::numeric),
('PLA Blu', 'Blu', '#1976D2', 'GLOSSY', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', '#FFA726', 'GLOSSY', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', '#BDBDBD', 'GLOSSY', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', '#424242', 'MATTE', true, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', '#D6D6D6', 'MATTE', true, false, 1.000::numeric),
('PLA Viola', 'Viola', '#7B1FA2', 'GLOSSY', false, false,
1.000::numeric)) as v(variant_display_name, color_name, color_hex, finish_type, is_matte, is_special, stock_spools)
on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name,
color_hex = excluded.color_hex,
finish_type = excluded.finish_type,
brand = excluded.brand,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
-- Varianti base per materiali principali del calcolatore
with mat as (select filament_material_type_id
from filament_material_type
where material_code = 'PLA TOUGH')
insert
into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand,
is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active)
select mat.filament_material_type_id,
'PLA Tough Nero',
'Nero',
'#1A1A1A',
'GLOSSY',
'Bambu',
false,
false,
18.00,
1.000,
1.000,
true
from mat
on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name,
color_hex = excluded.color_hex,
finish_type = excluded.finish_type,
brand = excluded.brand,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
with mat as (select filament_material_type_id
from filament_material_type
where material_code = 'PETG')
insert
into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand,
is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active)
select mat.filament_material_type_id,
'PETG Nero',
'Nero',
'#1A1A1A',
'GLOSSY',
'Bambu',
false,
false,
24.00,
1.000,
1.000,
true
from mat
on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name,
color_hex = excluded.color_hex,
finish_type = excluded.finish_type,
brand = excluded.brand,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
with mat as (select filament_material_type_id
from filament_material_type
where material_code = 'TPU')
insert
into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand,
is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active)
select mat.filament_material_type_id,
'TPU Nero',
'Nero',
'#1A1A1A',
'GLOSSY',
'Bambu',
false,
false,
42.00,
1.000,
1.000,
true
from mat
on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name,
color_hex = excluded.color_hex,
finish_type = excluded.finish_type,
brand = excluded.brand,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
with mat as (select filament_material_type_id
from filament_material_type
where material_code = 'PC')
insert
into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand,
is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active)
select mat.filament_material_type_id,
'PC Naturale',
'Naturale',
'#D9D9D9',
'TRANSLUCENT',
'Generic',
false,
true,
48.00,
1.000,
1.000,
true
from mat
on conflict (filament_material_type_id, variant_display_name) do update
set color_name = excluded.color_name,
color_hex = excluded.color_hex,
finish_type = excluded.finish_type,
brand = excluded.brand,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
@@ -326,6 +496,52 @@ on conflict (nozzle_diameter_mm) do update
extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
is_active = excluded.is_active;
-- =========================================================
-- 5b) Orca machine/material mapping (data-driven)
-- =========================================================
with a1 as (select printer_machine_id
from printer_machine
where printer_display_name = 'BambuLab A1')
insert
into printer_machine_profile (printer_machine_id, nozzle_diameter_mm, orca_machine_profile_name, is_default, is_active)
select a1.printer_machine_id, v.nozzle_diameter_mm, v.profile_name, v.is_default, true
from a1
cross join (values (0.40::numeric, 'Bambu Lab A1 0.4 nozzle', true),
(0.20::numeric, 'Bambu Lab A1 0.2 nozzle', false),
(0.60::numeric, 'Bambu Lab A1 0.6 nozzle', false),
(0.80::numeric, 'Bambu Lab A1 0.8 nozzle', false))
as v(nozzle_diameter_mm, profile_name, is_default)
on conflict (printer_machine_id, nozzle_diameter_mm) do update
set orca_machine_profile_name = excluded.orca_machine_profile_name,
is_default = excluded.is_default,
is_active = excluded.is_active;
with p as (select printer_machine_profile_id
from printer_machine_profile pmp
join printer_machine pm on pm.printer_machine_id = pmp.printer_machine_id
where pm.printer_display_name = 'BambuLab A1'
and pmp.nozzle_diameter_mm = 0.40::numeric),
m as (select filament_material_type_id, material_code
from filament_material_type
where material_code in ('PLA', 'PLA TOUGH', 'PETG', 'TPU', 'PC'))
insert
into material_orca_profile_map (printer_machine_profile_id, filament_material_type_id, orca_filament_profile_name, is_active)
select p.printer_machine_profile_id,
m.filament_material_type_id,
case m.material_code
when 'PLA' then 'Bambu PLA Basic @BBL A1'
when 'PLA TOUGH' then 'Bambu PLA Tough @BBL A1'
when 'PETG' then 'Bambu PETG Basic @BBL A1'
when 'TPU' then 'Bambu TPU 95A @BBL A1'
when 'PC' then 'Generic PC @BBL A1'
end,
true
from p
cross join m
on conflict (printer_machine_profile_id, filament_material_type_id) do update
set orca_filament_profile_name = excluded.orca_filament_profile_name,
is_active = excluded.is_active;
-- =========================================================
-- 6) Layer heights (opzioni)
@@ -421,6 +637,7 @@ CREATE TABLE IF NOT EXISTS quote_line_items
original_filename text NOT NULL,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
color_code text, -- es: white/black o codice interno
filament_variant_id bigint REFERENCES filament_variant (filament_variant_id),
-- Output slicing / calcolo
bounding_box_x_mm numeric(10, 3),
@@ -468,6 +685,7 @@ CREATE TABLE IF NOT EXISTS orders
customer_id uuid REFERENCES customers (customer_id),
customer_email text NOT NULL,
customer_phone text,
preferred_language char(2) NOT NULL DEFAULT 'it',
-- Snapshot indirizzo/fatturazione (evita tabella addresses e mantiene storico)
billing_customer_type text NOT NULL CHECK (billing_customer_type IN ('PRIVATE', 'COMPANY')),
@@ -529,12 +747,18 @@ CREATE TABLE IF NOT EXISTS order_items
sha256_hex text, -- opzionale, utile anche per dedup interno
material_code text NOT NULL,
filament_variant_id bigint REFERENCES filament_variant (filament_variant_id),
color_code text,
quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1),
-- Snapshot output
print_time_seconds integer CHECK (print_time_seconds >= 0),
material_grams numeric(12, 2) CHECK (material_grams >= 0),
bounding_box_x_mm numeric(10, 3),
bounding_box_y_mm numeric(10, 3),
bounding_box_z_mm numeric(10, 3),
unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0),
line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0),
@@ -552,7 +776,7 @@ CREATE TABLE IF NOT EXISTS payments
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
method text NOT NULL CHECK (method IN ('QR_BILL', 'TWINT', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF',

View File

@@ -10,4 +10,7 @@ FRONTEND_PORT=18082
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true
APP_FRONTEND_BASE_URL=https://dev.3d-fab.ch
ADMIN_PASSWORD=
ADMIN_SESSION_SECRET=
ADMIN_SESSION_TTL_MINUTES=480

View File

@@ -10,4 +10,7 @@ FRONTEND_PORT=18081
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true
APP_FRONTEND_BASE_URL=https://int.3d-fab.ch
ADMIN_PASSWORD=
ADMIN_SESSION_SECRET=
ADMIN_SESSION_TTL_MINUTES=480

View File

@@ -4,10 +4,13 @@ ENV=prod
TAG=prod
# Ports
BACKEND_PORT=8000
FRONTEND_PORT=80
BACKEND_PORT=18000
FRONTEND_PORT=18080
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true
APP_FRONTEND_BASE_URL=https://3d-fab.ch
ADMIN_PASSWORD=
ADMIN_SESSION_SECRET=
ADMIN_SESSION_TTL_MINUTES=480

View File

@@ -22,6 +22,10 @@ services:
- APP_MAIL_FROM=${APP_MAIL_FROM:-info@3d-fab.ch}
- APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true}
- APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch}
- APP_FRONTEND_BASE_URL=${APP_FRONTEND_BASE_URL:-http://localhost:4200}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
restart: always

View File

@@ -4,7 +4,7 @@ services:
container_name: print-calculator-db
environment:
- POSTGRES_USER=printcalc
- POSTGRES_PASSWORD=printcalc_secret
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=printcalc
ports:
- "5432:5432"

View File

@@ -107,6 +107,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"karmaConfig": "karma.conf.js",
"polyfills": [
"zone.js",
"zone.js/testing"

40
frontend/karma.conf.js Normal file
View File

@@ -0,0 +1,40 @@
// Karma config dedicated to CI-safe Chrome execution.
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
jasmine: {},
clearContext: false,
},
jasmineHtmlReporter: {
suppressAll: true,
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/frontend'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-dev-shm-usage'],
},
},
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -6,6 +6,6 @@ import { RouterOutlet } from '@angular/router';
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
styleUrl: './app.component.scss',
})
export class AppComponent {}

View File

@@ -1,27 +1,47 @@
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import {
ApplicationConfig,
provideZoneChangeDetection,
importProvidersFrom,
} from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
withViewTransitions,
} from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader';
import {
provideTranslateHttpLoader,
TranslateHttpLoader,
} from '@ngx-translate/http-loader';
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(),
provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions(),
withInMemoryScrolling({
scrollPositionRestoration: 'top',
}),
),
provideHttpClient(withInterceptors([adminAuthInterceptor])),
provideTranslateHttpLoader({
prefix: './assets/i18n/',
suffix: '.json'
suffix: '.json',
}),
importProvidersFrom(
TranslateModule.forRoot({
defaultLanguage: 'it',
loader: {
provide: TranslateLoader,
useClass: TranslateHttpLoader
}
})
)
]
useClass: TranslateHttpLoader,
},
}),
),
],
};

View File

@@ -1,54 +1,81 @@
import { Routes } from '@angular/router';
const appChildRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'calculator',
loadChildren: () =>
import('./features/calculator/calculator.routes').then(
(m) => m.CALCULATOR_ROUTES,
),
},
{
path: 'shop',
loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
},
{
path: 'about',
loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
},
{
path: 'contact',
loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
},
{
path: 'checkout',
loadComponent: () =>
import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent,
),
},
{
path: 'order/:orderId',
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
},
{
path: 'co/:orderId',
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
},
{
path: '',
loadChildren: () =>
import('./features/legal/legal.routes').then((m) => m.LEGAL_ROUTES),
},
{
path: 'admin',
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
{
path: '**',
redirectTo: '',
},
];
export const routes: Routes = [
{
path: ':lang',
loadComponent: () =>
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
},
{
path: '',
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
children: [
{
path: '',
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
},
{
path: 'calculator',
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
},
{
path: 'shop',
loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES)
},
{
path: 'about',
loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES)
},
{
path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
},
{
path: 'checkout',
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent)
},
{
path: 'payment/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{
path: 'ordine/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{
path: 'order-confirmed/:orderId',
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
},
{
path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
},
{
path: '**',
redirectTo: ''
}
]
}
loadComponent: () =>
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
},
{
path: '**',
redirectTo: '',
},
];

View File

@@ -2,6 +2,7 @@ export interface ColorOption {
label: string;
value: string;
hex: string;
variantId?: number;
outOfStock?: boolean;
}
@@ -12,30 +13,35 @@ export interface ColorCategory {
export const PRODUCT_COLORS: ColorCategory[] = [
{
name: 'Lucidi', // Glossy
name: 'COLOR.CATEGORY_GLOSSY',
colors: [
{ label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'White', value: 'White', hex: '#f5f5f5' },
{ label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
{ label: 'Blue', value: 'Blue', hex: '#1976d2' },
{ label: 'Green', value: 'Green', hex: '#388e3c' },
{ label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
]
{ label: 'COLOR.NAME.BLACK', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'COLOR.NAME.WHITE', value: 'White', hex: '#f5f5f5' },
{
label: 'COLOR.NAME.RED',
value: 'Red',
hex: '#d32f2f',
outOfStock: true,
},
{ label: 'COLOR.NAME.BLUE', value: 'Blue', hex: '#1976d2' },
{ label: 'COLOR.NAME.GREEN', value: 'Green', hex: '#388e3c' },
{ label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' },
],
},
{
name: 'Opachi', // Matte
name: 'COLOR.CATEGORY_MATTE',
colors: [
{ label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
]
}
{ label: 'COLOR.NAME.MATTE_BLACK', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'COLOR.NAME.MATTE_WHITE', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' },
],
},
];
export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find(c => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find((c) => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
}

View File

@@ -0,0 +1,41 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
function resolveLangFromUrl(url: string): string {
const cleanUrl = (url || '').split('?')[0].split('#')[0];
const segments = cleanUrl.split('/').filter(Boolean);
if (segments.length > 0 && SUPPORTED_LANGS.has(segments[0])) {
return segments[0];
}
return 'it';
}
export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.includes('/api/admin/')) {
return next(req);
}
const router = inject(Router);
const request = req.clone({ withCredentials: true });
const isLoginRequest = request.url.includes('/api/admin/auth/login');
return next(request).pipe(
catchError((error: unknown) => {
if (
!isLoginRequest &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
const lang = resolveLangFromUrl(router.url);
if (!router.url.includes('/admin/login')) {
void router.navigate(['/', lang, 'admin', 'login']);
}
}
return throwError(() => error);
}),
);
};

View File

@@ -1,21 +1,21 @@
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<div class="col links">
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a>
</div>
<div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
</div>
<div class="col social">
<!-- Social Placeholders -->
<div class="social-icon"></div>
<div class="social-icon"></div>
<div class="social-icon"></div>
</div>
</div>
</footer>
<div class="col social">
<!-- Social Placeholders -->
<div class="social-icon"></div>
<div class="social-icon"></div>
<div class="social-icon"></div>
</div>
</div>
</footer>

View File

@@ -1,59 +1,75 @@
@use '../../../styles/patterns';
@use "../../../styles/patterns";
.footer {
background: var(--color-neutral-900);
color: var(--color-neutral-50);
padding: var(--space-8) 0 var(--space-4);
font-size: 0.9rem;
position: relative;
margin-top: auto; /* Push to bottom if content is short */
// Cross Hatch Pattern
&::before {
content: '';
position: absolute;
inset: 0;
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
opacity: 0.05;
pointer-events: none;
}
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
.footer {
background: var(--color-neutral-900);
color: var(--color-neutral-50);
padding: var(--space-8) 0 var(--space-4);
font-size: 0.9rem;
position: relative;
margin-top: auto; /* Push to bottom if content is short */
// Cross Hatch Pattern
&::before {
content: "";
position: absolute;
inset: 0;
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
opacity: 0.05;
pointer-events: none;
}
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
text-align: center;
gap: var(--space-8);
}
.links {
flex-direction: column;
gap: var(--space-3);
}
}
.brand {
font-weight: 700;
color: white;
display: block;
margin-bottom: var(--space-2);
}
.copyright {
font-size: 0.875rem;
color: var(--color-secondary-500);
margin: 0;
}
.links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-neutral-300);
font-size: 0.875rem;
transition: color 0.2s;
&:hover {
color: white;
text-decoration: underline;
}
}
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
text-align: center;
gap: var(--space-8);
}
.links {
flex-direction: column;
gap: var(--space-3);
}
}
.brand { font-weight: 700; color: white; display: block; margin-bottom: var(--space-2); }
.copyright { font-size: 0.875rem; color: var(--color-secondary-500); margin: 0; }
.links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-neutral-300);
font-size: 0.875rem;
transition: color 0.2s;
&:hover { color: white; text-decoration: underline; }
}
}
.social { display: flex; gap: var(--space-3); }
.social-icon {
width: 24px; height: 24px;
background-color: var(--color-neutral-800);
border-radius: 50%;
}
.social {
display: flex;
gap: var(--space-3);
}
.social-icon {
width: 24px;
height: 24px;
background-color: var(--color-neutral-800);
border-radius: 50%;
}

View File

@@ -7,6 +7,6 @@ import { RouterLink } from '@angular/router';
standalone: true,
imports: [TranslateModule, RouterLink],
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent {}

View File

@@ -8,6 +8,6 @@ import { FooterComponent } from './footer.component';
standalone: true,
imports: [RouterOutlet, NavbarComponent, FooterComponent],
templateUrl: './layout.component.html',
styleUrl: './layout.component.scss'
styleUrl: './layout.component.scss',
})
export class LayoutComponent {}

View File

@@ -1,29 +1,74 @@
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<div class="mobile-toggle" (click)="toggleMenu()" [class.active]="isMenuOpen">
<span></span>
<span></span>
<span></span>
</div>
<div
class="mobile-toggle"
(click)="toggleMenu()"
[class.active]="isMenuOpen"
>
<span></span>
<span></span>
<span></span>
</div>
<nav class="nav-links" [class.open]="isMenuOpen">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
</nav>
<nav class="nav-links" [class.open]="isMenuOpen">
<a
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/contact"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
>
</nav>
<div class="actions">
<button class="lang-switch" (click)="toggleLang()">
{{ langService.currentLang() === 'it' ? 'EN' : 'IT' }}
</button>
<div class="actions">
<select
class="lang-switch"
[value]="langService.selectedLang()"
(change)="onLanguageChange($event)"
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate"
>
@for (option of languageOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
<div class="icon-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</div>
</div>
<div class="icon-placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</header>
</div>
</div>
</header>

View File

@@ -1,141 +1,175 @@
.navbar {
height: 64px;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-card);
position: sticky;
top: 0;
z-index: 100;
.navbar {
height: 64px;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-card);
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
}
.navbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.highlight {
color: var(--color-brand);
}
.nav-links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-text-muted);
font-weight: 500;
text-decoration: none;
transition: color 0.2s;
&:hover,
&.active {
color: var(--color-brand);
}
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
}
.lang-switch {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px 22px 2px 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted);
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 5px) calc(50% - 2px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
&:hover {
color: var(--color-text);
border-color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
}
.icon-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
/* Mobile Toggle */
.mobile-toggle {
display: none;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
cursor: pointer;
z-index: 101;
span {
display: block;
height: 2px;
width: 100%;
background-color: var(--color-text);
border-radius: 2px;
transition: all 0.3s ease;
}
&.active {
span:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
span:nth-child(2) {
opacity: 0;
}
span:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
}
}
/* Responsive Design */
@media (max-width: 768px) {
.mobile-toggle {
display: flex;
order: 2; /* Place after actions */
margin-left: var(--space-4);
}
.actions {
order: 1; /* Place before toggle */
margin-left: auto; /* Push to right */
}
.nav-links {
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: var(--color-bg-card);
flex-direction: column;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
gap: var(--space-4);
display: none;
z-index: 1000;
&.open {
display: flex;
align-items: center;
animation: slideDown 0.3s ease forwards;
}
.navbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.highlight { color: var(--color-brand); }
.nav-links {
display: flex;
gap: var(--space-6);
a {
font-size: 1.1rem;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
a {
color: var(--color-text-muted);
font-weight: 500;
text-decoration: none;
transition: color 0.2s;
&:hover, &.active {
color: var(--color-brand);
}
&:last-child {
border-bottom: none;
}
}
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
}
.lang-switch {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted);
&:hover { color: var(--color-text); border-color: var(--color-text); }
}
.icon-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
/* Mobile Toggle */
.mobile-toggle {
display: none;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
cursor: pointer;
z-index: 101;
span {
display: block;
height: 2px;
width: 100%;
background-color: var(--color-text);
border-radius: 2px;
transition: all 0.3s ease;
}
&.active {
span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
span:nth-child(2) { opacity: 0; }
span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
}
}
/* Responsive Design */
@media (max-width: 768px) {
.mobile-toggle {
display: flex;
order: 2; /* Place after actions */
margin-left: var(--space-4);
}
.actions {
order: 1; /* Place before toggle */
margin-left: auto; /* Push to right */
}
.nav-links {
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: var(--color-bg-card);
flex-direction: column;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
gap: var(--space-4);
display: none;
z-index: 1000;
&.open {
display: flex;
animation: slideDown 0.3s ease forwards;
}
a {
font-size: 1.1rem;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
&:last-child {
border-bottom: none;
}
}
}
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

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