125 Commits

Author SHA1 Message Date
43cd80600e Merge branch 'dev' into feat/brand-logo
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Failing after 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-20 11:28:10 +01:00
printcalc-ci
23e1abdbbb style: apply prettier formatting 2026-03-20 09:37:56 +00:00
e575021f53 feat(front-end): new logo edited
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 22s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Failing after 34s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-20 10:36:50 +01:00
7e8c89ce45 feat(front-end): new logo edited 2026-03-19 14:33:29 +01:00
a40a8df894 feat(animation logo) 2026-03-18 17:30:53 +01:00
printcalc-ci
41f36ed18a style: apply prettier formatting 2026-03-17 08:03:30 +00:00
e04189bbfe Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 22s
2026-03-17 09:01:34 +01:00
20988e425a fix(front-end): set fallback lang
Some checks failed
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / test-backend (push) Successful in 36s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / prettier-autofix (pull_request) Failing after 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-14 20:53:50 +01:00
df63937406 feat(front-end): faster load
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 19s
2026-03-14 19:28:30 +01:00
4ba408859d Merge pull request 'dev' (#48) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #48
2026-03-14 19:18:15 +01:00
996e95f93c fix(back-end): quote line items
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 32s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m25s
Build and Deploy / deploy (push) Successful in 21s
2026-03-14 19:15:44 +01:00
printcalc-ci
c4bd0b5a67 style: apply prettier formatting 2026-03-14 17:58:02 +00:00
5c43873ede Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 18s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 27s
2026-03-14 18:56:07 +01:00
249645619e fix(deploy): common..env
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 22s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-14 18:52:18 +01:00
be9f303b37 fix(deploy): common..env
Some checks failed
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Failing after 6s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-14 18:42:53 +01:00
6da8b3b6e4 feat(back-end): new translation api with openai
Some checks failed
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 1m20s
Build and Deploy / deploy (push) Failing after 6s
2026-03-14 18:33:51 +01:00
a3cd451575 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #47
2026-03-14 16:13:37 +01:00
printcalc-ci
6f700c923a style: apply prettier formatting
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 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-14 14:15:10 +00:00
46fd59ed71 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 22s
2026-03-14 15:14:12 +01:00
ba49463ee7 fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-14 15:13:54 +01:00
576380e9a0 fix(front-end): seo translated 2026-03-14 15:02:00 +01:00
cac534ccbb Merge pull request 'dev' (#46) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 18s
Reviewed-on: #46
2026-03-13 17:44:20 +01:00
2e68105da4 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 24s
2026-03-13 17:41:55 +01:00
ed7ed6636d fix(front-end): al categories translated 2026-03-13 17:41:25 +01:00
e190359041 Merge pull request 'dev' (#45) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #45
2026-03-13 16:36:42 +01:00
printcalc-ci
bed94790d4 style: apply prettier formatting
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
2026-03-13 15:30:28 +00:00
d8ad61ec54 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 21s
2026-03-13 16:28:40 +01:00
7e9a1482d6 fix(front-end): maximum budget
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-13 16:24:39 +01:00
aa0adbf993 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 41s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:21:57 +01:00
00af9a9701 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 1m15s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:16:49 +01:00
fcdede2dd6 chore(front-end):map color
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Failing after 26s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:43:00 +01:00
5d17b23c3a chore(front-end): new seo, and improvements in shop component
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 1m16s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:26:36 +01:00
1ec8a43a50 Merge pull request 'dev' (#43) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #43
2026-03-12 12:20:34 +01:00
96cfa91c67 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m25s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 12:17:42 +01:00
669ace82c0 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-12 12:17:28 +01:00
93163ae6e8 fix(front-end): sitemap static 2026-03-12 12:17:13 +01:00
af2d506da1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #42
2026-03-11 17:35:12 +01:00
637541994a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m1s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 22s
2026-03-11 17:32:53 +01:00
printcalc-ci
63cd4c4f5e style: apply prettier formatting 2026-03-11 16:31:15 +00:00
fd4104da39 fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-11 17:27:28 +01:00
5bb23fbcfa fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:23:32 +01:00
6a22c54e9f feat(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:19:26 +01:00
3ac3262e77 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 27s
Build and Deploy / deploy (push) Successful in 20s
# Conflicts:
#	frontend/src/app/app.config.ts
2026-03-11 17:10:06 +01:00
18ecc07240 feat(front-end): ssr i18n fix 2026-03-11 17:09:51 +01:00
cb468492b3 Merge pull request 'feat(front-end): ssr implementation' (#41) from feat/ssr into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 52s
Build and Deploy / deploy (push) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m0s
Reviewed-on: #41
2026-03-11 16:59:21 +01:00
379a2161ca Merge remote-tracking branch 'origin/feat/ssr' into feat/ssr
All checks were successful
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
2026-03-11 16:57:07 +01:00
c47a7e28c7 feat(front-end): ssr implementation 2026-03-11 16:57:03 +01:00
printcalc-ci
502126c915 style: apply prettier formatting 2026-03-11 15:37:29 +00:00
2ace632022 feat(front-end): ssr implementation
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Failing after 57s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-11 16:37:08 +01:00
feee2b0bff Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #40
2026-03-11 15:32:14 +01:00
b7dfc53bc0 feat(front-end): update header layout
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 48s
Build and Deploy / deploy (push) Successful in 11s
2026-03-11 15:30:21 +01:00
d77c3c7a5c feat(front-end): update header layout
Some checks failed
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 36s
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-11 15:28:40 +01:00
printcalc-ci
faaa59ae01 style: apply prettier formatting 2026-03-11 14:18:47 +00:00
1bde2690b6 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 1m41s
Build and Deploy / deploy (push) Has been skipped
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 28s
2026-03-11 15:17:52 +01:00
92fddd9e4a Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-11 15:17:32 +01:00
f9414b2db7 feat(front-end): sitemap update 2026-03-11 15:17:19 +01:00
d1e7e7eaca feat(front-end): sitemap 2026-03-11 15:07:12 +01:00
75929a15f8 Merge pull request 'fix(front-end): fix security' (#39) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #39
2026-03-11 11:42:50 +01:00
c0585d7107 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 31s
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 12s
2026-03-11 11:39:59 +01:00
aeeed1c138 fix(front-end): fix security
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 11:39:45 +01:00
10f05fabc9 Merge pull request 'dev' (#38) from dev into main
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 7s
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 16s
Reviewed-on: #38
2026-03-11 11:05:12 +01:00
78b719d3c2 fix(front-end): fix security
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 11:02:47 +01:00
printcalc-ci
052ade3e91 style: apply prettier formatting 2026-03-11 09:59:12 +00:00
035851133e Merge branch 'main' into dev
Some checks failed
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Failing after 27s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-11 10:57:23 +01:00
1ff8883d25 fix(back-end): fix hex color
Some checks failed
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 23s
Build and Deploy / deploy (push) Successful in 8s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Failing after 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-11 10:29:29 +01:00
30ada043ef fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 34s
Build and Deploy / deploy (push) Successful in 9s
2026-03-11 10:21:15 +01:00
e0aaa7c92e fix(back-end): fix hex color
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 1m43s
Build and Deploy / deploy (push) Successful in 10s
2026-03-11 10:14:30 +01:00
edae13541f feat(back-end): rich text improvements
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 27s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 19:01:17 +01:00
d150c19f9f feat(back-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 53s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 57s
Build and Deploy / deploy (push) Successful in 8s
2026-03-10 18:51:15 +01:00
71890e4cc2 feat(front-end): rich text
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 25s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 18:48:27 +01:00
95494efcae Merge pull request 'dev' (#37) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #37
2026-03-10 17:43:45 +01:00
8893a80c12 feat(front-end): update media ffmpeg
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / test-frontend (pull_request) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 1m40s
Build and Deploy / deploy (push) Successful in 11s
2026-03-10 17:29:38 +01:00
a4facf74d7 feat(front-end): update media ffmpeg
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 13s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m0s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 17:20:45 +01:00
ed8fd89217 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 9s
2026-03-10 15:32:59 +01:00
58869be9f7 feat(front-end): css allert fix 2026-03-10 15:32:53 +01:00
fda0174a77 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 36s
PR Checks / test-backend (pull_request) Successful in 29s
Build and Deploy / build-and-push (push) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 12s
2026-03-10 15:25:51 +01:00
printcalc-ci
52c6239f6d style: apply prettier formatting 2026-03-10 14:23:52 +00:00
c007cee093 feat(front-end): shop category ui
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / build-and-push (push) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 13s
2026-03-10 15:23:08 +01:00
d011268d20 feat(front-end): shop category ui
Some checks failed
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 44s
Build and Deploy / deploy (push) Has been skipped
2026-03-10 15:14:10 +01:00
126d3ef4c4 feat(back-end front-end): shop route 2026-03-10 15:04:56 +01:00
3f228ef6e2 feat(back-end front-end): shop feature 2026-03-10 15:04:49 +01:00
8733184dc5 Merge branch 'feat/shop' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 38s
Build and Deploy / deploy (push) Successful in 10s
2026-03-10 12:42:42 +01:00
5f815d8a54 fix(back-end) url construction for media 2026-03-10 12:42:26 +01:00
6d491eb694 Merge pull request 'feat/shop' (#36) from feat/shop into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 11s
Reviewed-on: #36
2026-03-10 11:16:22 +01:00
printcalc-ci
42e0e75d70 style: apply prettier formatting 2026-03-10 10:15:56 +00:00
c24e27a9db Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 26s
# Conflicts:
#	frontend/src/app/features/shop/shop-page.component.scss
2026-03-10 11:15:32 +01:00
3d12ae4da4 feat(front-end): ui improvements and alligment 2026-03-10 11:15:25 +01:00
printcalc-ci
b9e6916dfe style: apply prettier formatting 2026-03-10 09:54:16 +00:00
7cd9ef53b5 Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
# Conflicts:
#	frontend/src/app/features/shop/components/product-card/product-card.component.scss
#	frontend/src/app/features/shop/product-detail.component.scss
#	frontend/src/app/features/shop/shop-page.component.html
#	frontend/src/app/features/shop/shop-page.component.scss
#	frontend/src/app/features/shop/shop-page.component.ts
2026-03-10 10:53:42 +01:00
e747a9820e feat(front-end): back-office desing improvements 2026-03-10 10:53:30 +01:00
ba6940e64b Merge branch 'dev' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-10 09:21:28 +01:00
printcalc-ci
4342e9b1b1 style: apply prettier formatting 2026-03-10 07:32:17 +00:00
a212a1d8cc feat(back-end): shop ui implementation
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 34s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-10 08:31:29 +01:00
cd0c13203f feat(back-end): category and shop implementation 2026-03-09 20:32:13 +01:00
77d7bdb265 fix(back-end): img convert
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 39s
Build and Deploy / deploy (push) Successful in 8s
2026-03-09 20:04:21 +01:00
fb5e753769 feat(back-end) shop logic 2026-03-09 19:58:53 +01:00
c953232f3c feat(back-end) entities for shop 2026-03-09 19:50:00 +01:00
cd2666d8e2 Merge pull request 'feat/shop' (#35) from feat/shop into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #35
2026-03-09 19:31:12 +01:00
2f34c52b6f Merge branch 'dev' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-09 19:31:06 +01:00
printcalc-ci
2606b22185 style: apply prettier formatting 2026-03-09 18:22:55 +00:00
038e1634eb Merge pull request 'fix(front-end): css file reduced' (#34) from fix/css-files into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 59s
Reviewed-on: #34
2026-03-09 19:22:33 +01:00
f03f111d5e Merge pull request 'feat/shop' (#33) from feat/shop into dev
Some checks failed
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Failing after 25s
Build and Deploy / deploy (push) Has been skipped
Reviewed-on: #33
2026-03-09 19:21:27 +01:00
aaa58346d3 fix(front-end): css file reduced 2026-03-09 19:21:18 +01:00
506762c538 Merge branch 'dev' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 57s
2026-03-09 19:19:38 +01:00
printcalc-ci
492d474c82 style: apply prettier formatting 2026-03-09 18:09:45 +00:00
225995c892 Merge pull request 'fix(front-end): css file duplicte' (#32) from fix/css-files into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Reviewed-on: #32
2026-03-09 19:09:19 +01:00
dfe109ac8d fix(front-end): css file duplicte 2026-03-09 19:08:51 +01:00
ca22c0c461 Merge pull request 'feat/shop' (#31) from feat/shop into dev
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Failing after 1m32s
Build and Deploy / deploy (push) Has been skipped
Reviewed-on: #31
2026-03-09 18:52:25 +01:00
8afab3e58e Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m5s
PR Checks / security-sast (pull_request) Successful in 32s
2026-03-09 18:49:34 +01:00
b4462dcd9d feat(back-end front-end):ffmpeg local 2026-03-09 18:49:25 +01:00
printcalc-ci
f598e376a6 style: apply prettier formatting 2026-03-09 17:49:19 +00:00
e8ebef926e feat(back-end front-end): traslate alt and description images
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m4s
2026-03-09 18:49:03 +01:00
85598dee3b Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-09 18:14:25 +01:00
2dbf7e9c09 fix(back-end): security problem 2026-03-09 18:14:19 +01:00
printcalc-ci
210820185b style: apply prettier formatting 2026-03-09 17:08:05 +00:00
7615b8b601 feat(front-end): admin desing improvements
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 20s
PR Checks / security-sast (pull_request) Failing after 34s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m0s
2026-03-09 18:07:22 +01:00
17df0c6b9b feat(back-end): admin home edit image page 2026-03-09 17:44:17 +01:00
9e306ea1d1 feat(back-end): upload media 2026-03-09 16:30:00 +01:00
3ab518a9b6 Merge pull request 'dev' (#29) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 18s
Build and Deploy / deploy (push) Successful in 9s
Reviewed-on: #29
2026-03-09 09:58:45 +01:00
63804e7561 fix(back-end): nozle layer height
All checks were successful
Build and Deploy / test-backend (push) Successful in 41s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m14s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 1m10s
Build and Deploy / deploy (push) Successful in 11s
2026-03-08 18:47:50 +01:00
0c4800443f Merge pull request 'feat/calculator-options' (#30) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 50s
PR Checks / prettier-autofix (pull_request) Successful in 9s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / deploy (push) Successful in 13s
Reviewed-on: #30
2026-03-06 15:14:29 +01:00
1bd6a43614 Merge pull request 'feat/calculator-options' (#28) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 36s
PR Checks / prettier-autofix (pull_request) Successful in 12s
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 25s
Build and Deploy / build-and-push (push) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 13s
Reviewed-on: #28
2026-03-06 12:55:05 +01:00
47c442aba9 Merge pull request 'Merge pull request 'fix(back-end): twint url' (#24) from fix/twint into main' (#27) from main into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m7s
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 26s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 13s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Reviewed-on: #27
2026-03-05 21:54:27 +01:00
00825b1002 Merge pull request 'feat/calculator-options' (#26) from feat/calculator-options into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 50s
Build and Deploy / deploy (push) Successful in 10s
Reviewed-on: #26
2026-03-05 20:50:33 +01:00
40da5ff1b7 Merge pull request 'fix(back-end): twint url' (#24) from fix/twint into main
All checks were successful
Build and Deploy / build-and-push (push) Successful in 36s
Build and Deploy / deploy (push) Successful in 8s
Build and Deploy / test-backend (push) Successful in 25s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 7s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 25s
PR Checks / test-frontend (pull_request) Successful in 1m7s
Reviewed-on: #24
2026-03-05 15:46:48 +01:00
295 changed files with 47870 additions and 2859 deletions

View File

@@ -125,6 +125,18 @@ jobs:
docker build -t "$FRONTEND_IMAGE" ./frontend docker build -t "$FRONTEND_IMAGE" ./frontend
docker push "$FRONTEND_IMAGE" docker push "$FRONTEND_IMAGE"
- name: Cleanup Docker on runner (prevent vdisk growth)
if: always()
shell: bash
run: |
set +e
# Keep recent artifacts, drop old local residue from CI builds.
docker container prune -f --filter "until=168h" || true
docker image prune -a -f --filter "until=168h" || true
docker builder prune -a -f --filter "until=168h" || true
docker network prune -f --filter "until=168h" || true
deploy: deploy:
needs: build-and-push needs: build-and-push
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -205,9 +217,12 @@ jobs:
ADMIN_TTL="${ADMIN_TTL:-480}" ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ 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 "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
fi
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env

6
.gitignore vendored
View File

@@ -44,8 +44,14 @@ build/
./storage_orders ./storage_orders
./storage_quotes ./storage_quotes
./storage_requests
./storage_media
./storage_shop
storage_orders storage_orders
storage_quotes storage_quotes
storage_requests
storage_media
storage_shop
# Qodana local reports/artifacts # Qodana local reports/artifacts
backend/.qodana/ backend/.qodana/

View File

@@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s
## Stack Tecnologico ## Stack Tecnologico
- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. - **Backend**: Java 21, Spring Boot 3.4, PostgreSQL.
- **Frontend**: Angular 19, Angular Material, Three.js. - **Frontend**: Angular 19, Angular Material, Three.js.
- **Slicer**: OrcaSlicer (invocato via CLI). - **Slicer**: OrcaSlicer (invocato via CLI).
@@ -21,14 +21,20 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s
* **Node.js 22** e **npm** installati. * **Node.js 22** e **npm** installati.
* **PostgreSQL** attivo. * **PostgreSQL** attivo.
* **OrcaSlicer** installato sul sistema. * **OrcaSlicer** installato sul sistema.
* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend.
## Avvio Rapido ## Avvio Rapido
### 1. Database ### 1. Database
Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal progetto tramite configurazione JPA/SQL del repository.
### 2. Backend ### 2. Backend
Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche:
- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`)
- `SHOP_STORAGE_ROOT` per la root `storage_shop` usata dal backend per i modelli dei prodotti shop
- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` (nel deploy Docker default: `/usr/local/bin/ffmpeg-media`)
- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine
```bash ```bash
cd backend cd backend
@@ -57,11 +63,54 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab
* `/backend`: API Spring Boot. * `/backend`: API Spring Boot.
* `/frontend`: Applicazione Angular. * `/frontend`: Applicazione Angular.
* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer.
* `/storage_media`: Originali e varianti media pubbliche/private su filesystem.
* `/storage_shop`: Modelli e file prodotti dello shop.
## Media pubblici
Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici.
Nel deploy Docker i volumi attesi sono `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media` e `/mnt/cache/appdata/print-calculator/${ENV}/storage_shop:/app/storage_shop`.
Nginx non deve passare dal backend per i file pubblici. Configurazione attesa:
```nginx
location /media/ {
alias /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/;
}
```
Usage key iniziali previste per frontend:
- `HOME_SECTION / shop-gallery`
- `HOME_SECTION / founders-gallery`
- `HOME_SECTION / capability-prototyping`
- `HOME_SECTION / capability-custom-parts`
- `HOME_SECTION / capability-small-series`
- `HOME_SECTION / capability-cad`
- `ABOUT_MEMBER / joe`
- `ABOUT_MEMBER / matteo`
- riservati per estensioni future: `SHOP_PRODUCT`, `SHOP_CATEGORY`, `SHOP_GALLERY`
Operativamente:
- carica i file dal media admin endpoint del backend
- associa ogni asset con `POST /api/admin/media/usages`
- per `ABOUT_MEMBER` imposta `isPrimary=true` sulla foto principale del membro
- home e about leggono da `GET /api/public/media/usages?usageType=...&usageKey=...`
- il frontend usa `<picture>` e preferisce AVIF/WEBP con fallback JPEG, senza usare l'originale
- nel back-office frontend la gestione operativa della home passa dalla pagina `admin/home-media`
## Troubleshooting ## Troubleshooting
### Percorso OrcaSlicer ### Percorso OrcaSlicer
Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno).
### FFmpeg e media pubblici
Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF (encoder + muxer AVIF). Nel container backend il default è `/usr/local/bin/ffmpeg-media`: usa `/usr/bin/ffmpeg` se già compatibile, altrimenti installa un fallback statico con supporto AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `APP_FRONTEND_BASE_URL` punti al dominio corretto, che `location /media/` sia esposto da Nginx e che il volume `storage_media` sia montato correttamente.
### Database connection ### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
### Deploy e traduzioni OpenAI
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.

View File

@@ -12,17 +12,49 @@ RUN ./gradlew bootJar -x test --no-daemon
FROM eclipse-temurin:21-jre-jammy FROM eclipse-temurin:21-jre-jammy
ARG ORCA_VERSION=2.3.1 ARG ORCA_VERSION=2.3.1
ARG ORCA_DOWNLOAD_URL ARG ORCA_DOWNLOAD_URL
ARG FFMPEG_STATIC_URL=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
# Install system dependencies for OrcaSlicer (same as before) # Install system dependencies for OrcaSlicer and media processing.
RUN apt-get update && apt-get install -y \ # Prefer system ffmpeg; if AVIF support is incomplete, install a static ffmpeg fallback.
RUN set -eux; \
apt-get update; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ffmpeg \
wget \ wget \
xz-utils \
ca-certificates \
assimp-utils \ assimp-utils \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37 \ libwebkit2gtk-4.0-37; \
&& rm -rf /var/lib/apt/lists/* check_ffmpeg_support() { \
ffmpeg_bin="$1"; \
"$ffmpeg_bin" -hide_banner -encoders > /tmp/ffmpeg-encoders.txt 2>&1 || return 1; \
"$ffmpeg_bin" -hide_banner -muxers > /tmp/ffmpeg-muxers.txt 2>&1 || return 1; \
grep -Eq '[[:space:]]mjpeg[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' /tmp/ffmpeg-encoders.txt || return 1; \
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' /tmp/ffmpeg-muxers.txt || return 1; \
return 0; \
}; \
if check_ffmpeg_support /usr/bin/ffmpeg; then \
ln -sf /usr/bin/ffmpeg /usr/local/bin/ffmpeg-media; \
else \
echo "System ffmpeg lacks AVIF support, installing static fallback from ${FFMPEG_STATIC_URL}"; \
wget -q "${FFMPEG_STATIC_URL}" -O /tmp/ffmpeg-static.tar.xz; \
tar -xJf /tmp/ffmpeg-static.tar.xz -C /tmp; \
FFMPEG_STATIC_BIN="$(find /tmp -maxdepth 2 -type f -name ffmpeg | head -n 1)"; \
test -n "${FFMPEG_STATIC_BIN}"; \
install -m 0755 "${FFMPEG_STATIC_BIN}" /usr/local/bin/ffmpeg-media; \
check_ffmpeg_support /usr/local/bin/ffmpeg-media; \
fi; \
rm -f /tmp/ffmpeg-muxers.txt; \
rm -f /tmp/ffmpeg-encoders.txt; \
rm -f /tmp/ffmpeg-static.tar.xz; \
rm -rf /tmp/ffmpeg-*-amd64-static; \
rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer # Install OrcaSlicer
WORKDIR /opt WORKDIR /opt
@@ -62,6 +94,8 @@ ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp" ENV ASSIMP_PATH="assimp"
# Use ffmpeg selected at image build time (system or static fallback) for media generation.
ENV MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -42,6 +42,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.jsoup:jsoup:1.18.3'
implementation platform('org.lwjgl:lwjgl-bom:3.3.4') implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
implementation 'org.lwjgl:lwjgl' implementation 'org.lwjgl:lwjgl'
implementation 'org.lwjgl:lwjgl-assimp' implementation 'org.lwjgl:lwjgl-assimp'

View File

@@ -1,10 +1,61 @@
#!/bin/sh #!/bin/sh
set -e
# In container default to the ffmpeg selected during image build.
if [ -z "${MEDIA_FFMPEG_PATH:-}" ]; then
MEDIA_FFMPEG_PATH="/usr/local/bin/ffmpeg-media"
fi
export MEDIA_FFMPEG_PATH
validate_ffmpeg_support() {
ffmpeg_bin="$1"
if ! command -v "$ffmpeg_bin" >/dev/null 2>&1; then
echo "ERROR: FFmpeg executable not found: ${ffmpeg_bin}" >&2
exit 11
fi
encoders="$(mktemp)"
muxers="$(mktemp)"
trap 'rm -f "$encoders" "$muxers"' EXIT
"$ffmpeg_bin" -hide_banner -encoders > "$encoders" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg encoders from ${ffmpeg_bin}" >&2
cat "$encoders" >&2
exit 12
}
"$ffmpeg_bin" -hide_banner -muxers > "$muxers" 2>&1 || {
echo "ERROR: Unable to inspect FFmpeg muxers from ${ffmpeg_bin}" >&2
cat "$muxers" >&2
exit 13
}
grep -Eq '[[:space:]]mjpeg[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing JPEG encoder (mjpeg)." >&2
exit 14
}
grep -Eq '[[:space:]](libwebp|webp)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing WebP encoder." >&2
exit 15
}
grep -Eq '[[:space:]](libaom-av1|librav1e|libsvtav1)[[:space:]]' "$encoders" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF-capable encoder." >&2
exit 16
}
grep -Eq '[[:space:]]avif([[:space:]]|,|$)' "$muxers" || {
echo "ERROR: FFmpeg '${ffmpeg_bin}' missing AVIF muxer." >&2
exit 17
}
}
validate_ffmpeg_support "$MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "MEDIA_FFMPEG_PATH: $MEDIA_FFMPEG_PATH"
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Determine which environment variables to use for database connection

View File

@@ -94,6 +94,10 @@ public class OptionsController {
v.getId(), v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
v.getColorLabelIt(),
v.getColorLabelEn(),
v.getColorLabelDe(),
v.getColorLabelFr(),
resolveHexColor(v), resolveHexColor(v),
v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,

View File

@@ -0,0 +1,31 @@
package com.printcalculator.controller;
import com.printcalculator.dto.PublicMediaUsageDto;
import com.printcalculator.service.media.PublicMediaQueryService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/public/media")
@Transactional(readOnly = true)
public class PublicMediaController {
private final PublicMediaQueryService publicMediaQueryService;
public PublicMediaController(PublicMediaQueryService publicMediaQueryService) {
this.publicMediaQueryService = publicMediaQueryService;
}
@GetMapping("/usages")
public ResponseEntity<List<PublicMediaUsageDto>> getUsageMedia(@RequestParam String usageType,
@RequestParam String usageKey,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicMediaQueryService.getUsageMedia(usageType, usageKey, lang));
}
}

View File

@@ -0,0 +1,77 @@
package com.printcalculator.controller;
import com.printcalculator.dto.ShopCategoryDetailDto;
import com.printcalculator.dto.ShopCategoryTreeDto;
import com.printcalculator.dto.ShopProductCatalogResponseDto;
import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.service.shop.PublicShopCatalogService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.List;
@RestController
@RequestMapping("/api/shop")
@Transactional(readOnly = true)
public class PublicShopController {
private final PublicShopCatalogService publicShopCatalogService;
public PublicShopController(PublicShopCatalogService publicShopCatalogService) {
this.publicShopCatalogService = publicShopCatalogService;
}
@GetMapping("/categories")
public ResponseEntity<List<ShopCategoryTreeDto>> getCategories(@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getCategories(lang));
}
@GetMapping("/categories/{slug}")
public ResponseEntity<ShopCategoryDetailDto> getCategory(@PathVariable String slug,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getCategory(slug, lang));
}
@GetMapping("/products")
public ResponseEntity<ShopProductCatalogResponseDto> getProducts(
@RequestParam(required = false) String categorySlug,
@RequestParam(required = false) Boolean featured,
@RequestParam(required = false) String lang
) {
return ResponseEntity.ok(publicShopCatalogService.getProductCatalog(categorySlug, featured, lang));
}
@GetMapping("/products/{slug}")
public ResponseEntity<ShopProductDetailDto> getProduct(@PathVariable String slug,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
}
@GetMapping("/products/{slug}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
Resource resource = new UrlResource(model.path().toUri());
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (model.mimeType() != null && !model.mimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(model.mimeType());
} catch (IllegalArgumentException ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"")
.body(resource);
}
}

View File

@@ -18,6 +18,7 @@ import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
@@ -124,6 +125,9 @@ public class QuoteController {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (!isSupportedInputFile(file)) {
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
// Scan for virus // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
@@ -153,4 +157,14 @@ public class QuoteController {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
} }
} }
private boolean isSupportedInputFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
return false;
}
String normalized = originalFilename.toLowerCase(Locale.ROOT);
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
}
} }

View File

@@ -62,6 +62,7 @@ public class QuoteSessionController {
public ResponseEntity<QuoteSession> createSession() { public ResponseEntity<QuoteSession> createSession() {
QuoteSession session = new QuoteSession(); QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE"); session.setStatus("ACTIVE");
session.setSessionType("PRINT_QUOTE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
session.setMaterialCode("PLA"); session.setMaterialCode("PLA");
session.setSupportsEnabled(false); session.setSupportsEnabled(false);
@@ -129,6 +130,7 @@ public class QuoteSessionController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
@Transactional(readOnly = true)
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) { public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id) QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found")); .orElseThrow(() -> new RuntimeException("Session not found"));

View File

@@ -0,0 +1,85 @@
package com.printcalculator.controller;
import com.printcalculator.dto.ShopCartAddItemRequest;
import com.printcalculator.dto.ShopCartUpdateItemRequest;
import com.printcalculator.service.shop.ShopCartCookieService;
import com.printcalculator.service.shop.ShopCartService;
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.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.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.UUID;
@RestController
@RequestMapping("/api/shop/cart")
public class ShopCartController {
private final ShopCartService shopCartService;
private final ShopCartCookieService shopCartCookieService;
public ShopCartController(ShopCartService shopCartService, ShopCartCookieService shopCartCookieService) {
this.shopCartService = shopCartService;
this.shopCartCookieService = shopCartCookieService;
}
@GetMapping
public ResponseEntity<?> getCart(HttpServletRequest request, HttpServletResponse response) {
ShopCartService.CartResult result = shopCartService.loadCart(request);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@PostMapping("/items")
public ResponseEntity<?> addItem(HttpServletRequest request,
HttpServletResponse response,
@Valid @RequestBody ShopCartAddItemRequest payload) {
ShopCartService.CartResult result = shopCartService.addItem(request, payload);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@PatchMapping("/items/{lineItemId}")
public ResponseEntity<?> updateItem(HttpServletRequest request,
HttpServletResponse response,
@PathVariable UUID lineItemId,
@Valid @RequestBody ShopCartUpdateItemRequest payload) {
ShopCartService.CartResult result = shopCartService.updateItem(request, lineItemId, payload);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@DeleteMapping("/items/{lineItemId}")
public ResponseEntity<?> removeItem(HttpServletRequest request,
HttpServletResponse response,
@PathVariable UUID lineItemId) {
ShopCartService.CartResult result = shopCartService.removeItem(request, lineItemId);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
@DeleteMapping
public ResponseEntity<?> clearCart(HttpServletRequest request, HttpServletResponse response) {
ShopCartService.CartResult result = shopCartService.clearCart(request);
applyCookie(response, result);
return ResponseEntity.ok(result.response());
}
private void applyCookie(HttpServletResponse response, ShopCartService.CartResult result) {
if (result.clearCookie()) {
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildClearCookie().toString());
return;
}
if (result.sessionId() != null) {
response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildSessionCookie(result.sessionId()).toString());
}
}
}

View File

@@ -0,0 +1,37 @@
package com.printcalculator.controller;
import com.printcalculator.service.shop.ShopSitemapService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
@RestController
public class SitemapController {
private final ShopSitemapService shopSitemapService;
private final long cacheSeconds;
public SitemapController(
ShopSitemapService shopSitemapService,
@Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds
) {
this.shopSitemapService = shopSitemapService;
this.cacheSeconds = Math.max(cacheSeconds, 0L);
}
@GetMapping(value = "/api/sitemap-shop.xml", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> getShopSitemap() {
CacheControl cacheControl = cacheSeconds > 0
? CacheControl.maxAge(Duration.ofSeconds(cacheSeconds)).cachePublic()
: CacheControl.noCache();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("application/xml;charset=UTF-8"))
.cacheControl(cacheControl)
.body(shopSitemapService.getShopSitemapXml());
}
}

View File

@@ -0,0 +1,89 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminCreateMediaUsageRequest;
import com.printcalculator.dto.AdminMediaAssetDto;
import com.printcalculator.dto.AdminMediaUsageDto;
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
import com.printcalculator.dto.AdminUpdateMediaUsageRequest;
import com.printcalculator.service.admin.AdminMediaControllerService;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/media")
@Transactional(readOnly = true)
public class AdminMediaController {
private final AdminMediaControllerService adminMediaControllerService;
public AdminMediaController(AdminMediaControllerService adminMediaControllerService) {
this.adminMediaControllerService = adminMediaControllerService;
}
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional
public ResponseEntity<AdminMediaAssetDto> uploadAsset(@RequestParam("file") MultipartFile file,
@RequestParam(value = "title", required = false) String title,
@RequestParam(value = "altText", required = false) String altText,
@RequestParam(value = "visibility", required = false) String visibility) {
return ResponseEntity.ok(adminMediaControllerService.uploadAsset(file, title, altText, visibility));
}
@GetMapping("/assets")
public ResponseEntity<List<AdminMediaAssetDto>> listAssets() {
return ResponseEntity.ok(adminMediaControllerService.listAssets());
}
@GetMapping("/assets/{mediaAssetId}")
public ResponseEntity<AdminMediaAssetDto> getAsset(@PathVariable UUID mediaAssetId) {
return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId));
}
@GetMapping("/usages")
public ResponseEntity<List<AdminMediaUsageDto>> getUsages(@RequestParam String usageType,
@RequestParam String usageKey,
@RequestParam(required = false) UUID ownerId) {
return ResponseEntity.ok(adminMediaControllerService.getUsages(usageType, usageKey, ownerId));
}
@PatchMapping("/assets/{mediaAssetId}")
@Transactional
public ResponseEntity<AdminMediaAssetDto> updateAsset(@PathVariable UUID mediaAssetId,
@RequestBody AdminUpdateMediaAssetRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload));
}
@PostMapping("/usages")
@Transactional
public ResponseEntity<AdminMediaUsageDto> createUsage(@RequestBody AdminCreateMediaUsageRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.createUsage(payload));
}
@PatchMapping("/usages/{mediaUsageId}")
@Transactional
public ResponseEntity<AdminMediaUsageDto> updateUsage(@PathVariable UUID mediaUsageId,
@RequestBody AdminUpdateMediaUsageRequest payload) {
return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload));
}
@DeleteMapping("/usages/{mediaUsageId}")
@Transactional
public ResponseEntity<Void> deleteUsage(@PathVariable UUID mediaUsageId) {
adminMediaControllerService.deleteUsage(mediaUsageId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,64 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopCategoryDto;
import com.printcalculator.dto.AdminUpsertShopCategoryRequest;
import com.printcalculator.service.admin.AdminShopCategoryControllerService;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/shop/categories")
@Transactional(readOnly = true)
public class AdminShopCategoryController {
private final AdminShopCategoryControllerService adminShopCategoryControllerService;
public AdminShopCategoryController(AdminShopCategoryControllerService adminShopCategoryControllerService) {
this.adminShopCategoryControllerService = adminShopCategoryControllerService;
}
@GetMapping
public ResponseEntity<List<AdminShopCategoryDto>> getCategories() {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategories());
}
@GetMapping("/tree")
public ResponseEntity<List<AdminShopCategoryDto>> getCategoryTree() {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategoryTree());
}
@GetMapping("/{categoryId}")
public ResponseEntity<AdminShopCategoryDto> getCategory(@PathVariable UUID categoryId) {
return ResponseEntity.ok(adminShopCategoryControllerService.getCategory(categoryId));
}
@PostMapping
@Transactional
public ResponseEntity<AdminShopCategoryDto> createCategory(@RequestBody AdminUpsertShopCategoryRequest payload) {
return ResponseEntity.ok(adminShopCategoryControllerService.createCategory(payload));
}
@PutMapping("/{categoryId}")
@Transactional
public ResponseEntity<AdminShopCategoryDto> updateCategory(@PathVariable UUID categoryId,
@RequestBody AdminUpsertShopCategoryRequest payload) {
return ResponseEntity.ok(adminShopCategoryControllerService.updateCategory(categoryId, payload));
}
@DeleteMapping("/{categoryId}")
@Transactional
public ResponseEntity<Void> deleteCategory(@PathVariable UUID categoryId) {
adminShopCategoryControllerService.deleteCategory(categoryId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,110 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopProductDto;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.dto.AdminUpsertShopProductRequest;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/admin/shop/products")
@Transactional(readOnly = true)
public class AdminShopProductController {
private final AdminShopProductControllerService adminShopProductControllerService;
private final AdminShopProductTranslationService adminShopProductTranslationService;
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
AdminShopProductTranslationService adminShopProductTranslationService) {
this.adminShopProductControllerService = adminShopProductControllerService;
this.adminShopProductTranslationService = adminShopProductTranslationService;
}
@GetMapping
public ResponseEntity<List<AdminShopProductDto>> getProducts() {
return ResponseEntity.ok(adminShopProductControllerService.getProducts());
}
@GetMapping("/{productId}")
public ResponseEntity<AdminShopProductDto> getProduct(@PathVariable UUID productId) {
return ResponseEntity.ok(adminShopProductControllerService.getProduct(productId));
}
@PostMapping
@Transactional
public ResponseEntity<AdminShopProductDto> createProduct(@RequestBody AdminUpsertShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
}
@PostMapping("/translate")
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
}
@PutMapping("/{productId}")
@Transactional
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
@RequestBody AdminUpsertShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductControllerService.updateProduct(productId, payload));
}
@DeleteMapping("/{productId}")
@Transactional
public ResponseEntity<Void> deleteProduct(@PathVariable UUID productId) {
adminShopProductControllerService.deleteProduct(productId);
return ResponseEntity.noContent().build();
}
@PostMapping("/{productId}/model")
@Transactional
public ResponseEntity<AdminShopProductDto> uploadProductModel(@PathVariable UUID productId,
@RequestParam("file") MultipartFile file) throws IOException {
return ResponseEntity.ok(adminShopProductControllerService.uploadProductModel(productId, file));
}
@DeleteMapping("/{productId}/model")
@Transactional
public ResponseEntity<Void> deleteProductModel(@PathVariable UUID productId) {
adminShopProductControllerService.deleteProductModel(productId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{productId}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable UUID productId) throws IOException {
AdminShopProductControllerService.ProductModelDownload model = adminShopProductControllerService.getProductModel(productId);
Resource resource = new UrlResource(model.path().toUri());
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (model.mimeType() != null && !model.mimeType().isBlank()) {
try {
contentType = MediaType.parseMediaType(model.mimeType());
} catch (IllegalArgumentException ignored) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
}
return ResponseEntity.ok()
.contentType(contentType)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"")
.body(resource);
}
}

View File

@@ -0,0 +1,79 @@
package com.printcalculator.dto;
import java.util.UUID;
import java.util.Map;
public class AdminCreateMediaUsageRequest {
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
}

View File

@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
private String materialTechnicalTypeLabel; private String materialTechnicalTypeLabel;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -0,0 +1,153 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class AdminMediaAssetDto {
private UUID id;
private String originalFilename;
private String storageKey;
private String mimeType;
private Long fileSizeBytes;
private String sha256Hex;
private Integer widthPx;
private Integer heightPx;
private String status;
private String visibility;
private String title;
private String altText;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private List<AdminMediaVariantDto> variants = new ArrayList<>();
private List<AdminMediaUsageDto> usages = new ArrayList<>();
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 getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
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 String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
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<AdminMediaVariantDto> getVariants() {
return variants;
}
public void setVariants(List<AdminMediaVariantDto> variants) {
this.variants = variants;
}
public List<AdminMediaUsageDto> getUsages() {
return usages;
}
public void setUsages(List<AdminMediaUsageDto> usages) {
this.usages = usages;
}
}

View File

@@ -0,0 +1,98 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
public class AdminMediaUsageDto {
private UUID id;
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,106 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminMediaVariantDto {
private UUID id;
private String variantName;
private String format;
private String storageKey;
private String mimeType;
private Integer widthPx;
private Integer heightPx;
private Long fileSizeBytes;
private Boolean isGenerated;
private String publicUrl;
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getVariantName() {
return variantName;
}
public void setVariantName(String variantName) {
this.variantName = variantName;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public Boolean getIsGenerated() {
return isGenerated;
}
public void setIsGenerated(Boolean generated) {
isGenerated = generated;
}
public String getPublicUrl() {
return publicUrl;
}
public void setPublicUrl(String publicUrl) {
this.publicUrl = publicUrl;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -7,6 +7,7 @@ import java.util.UUID;
public class AdminQuoteSessionDto { public class AdminQuoteSessionDto {
private UUID id; private UUID id;
private String status; private String status;
private String sessionType;
private String materialCode; private String materialCode;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private OffsetDateTime expiresAt; private OffsetDateTime expiresAt;
@@ -32,6 +33,14 @@ public class AdminQuoteSessionDto {
this.status = status; this.status = status;
} }
public String getSessionType() {
return sessionType;
}
public void setSessionType(String sessionType) {
this.sessionType = sessionType;
}
public String getMaterialCode() { public String getMaterialCode() {
return materialCode; return materialCode;
} }

View File

@@ -0,0 +1,359 @@
package com.printcalculator.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminShopCategoryDto {
private UUID id;
private UUID parentCategoryId;
private String parentCategoryName;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isActive;
private Integer sortOrder;
private Integer depth;
private Integer childCount;
private Integer directProductCount;
private Integer descendantProductCount;
private String mediaUsageType;
private String mediaUsageKey;
private List<AdminShopCategoryRefDto> breadcrumbs;
private List<AdminShopCategoryDto> children;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getParentCategoryId() {
return parentCategoryId;
}
public void setParentCategoryId(UUID parentCategoryId) {
this.parentCategoryId = parentCategoryId;
}
public String getParentCategoryName() {
return parentCategoryName;
}
public void setParentCategoryName(String parentCategoryName) {
this.parentCategoryName = parentCategoryName;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getDepth() {
return depth;
}
public void setDepth(Integer depth) {
this.depth = depth;
}
public Integer getChildCount() {
return childCount;
}
public void setChildCount(Integer childCount) {
this.childCount = childCount;
}
public Integer getDirectProductCount() {
return directProductCount;
}
public void setDirectProductCount(Integer directProductCount) {
this.directProductCount = directProductCount;
}
public Integer getDescendantProductCount() {
return descendantProductCount;
}
public void setDescendantProductCount(Integer descendantProductCount) {
this.descendantProductCount = descendantProductCount;
}
public String getMediaUsageType() {
return mediaUsageType;
}
public void setMediaUsageType(String mediaUsageType) {
this.mediaUsageType = mediaUsageType;
}
public String getMediaUsageKey() {
return mediaUsageKey;
}
public void setMediaUsageKey(String mediaUsageKey) {
this.mediaUsageKey = mediaUsageKey;
}
public List<AdminShopCategoryRefDto> getBreadcrumbs() {
return breadcrumbs;
}
public void setBreadcrumbs(List<AdminShopCategoryRefDto> breadcrumbs) {
this.breadcrumbs = breadcrumbs;
}
public List<AdminShopCategoryDto> getChildren() {
return children;
}
public void setChildren(List<AdminShopCategoryDto> children) {
this.children = children;
}
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;
}
}

View File

@@ -0,0 +1,33 @@
package com.printcalculator.dto;
import java.util.UUID;
public class AdminShopCategoryRefDto {
private UUID id;
private String slug;
private String name;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,441 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public class AdminShopProductDto {
private UUID id;
private UUID categoryId;
private String categoryName;
private String categorySlug;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String excerpt;
private String excerptIt;
private String excerptEn;
private String excerptDe;
private String excerptFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isFeatured;
private Boolean isActive;
private Integer sortOrder;
private Integer variantCount;
private Integer activeVariantCount;
private BigDecimal priceFromChf;
private BigDecimal priceToChf;
private String mediaUsageType;
private String mediaUsageKey;
private List<AdminMediaUsageDto> mediaUsages;
private List<PublicMediaUsageDto> images;
private ShopProductModelDto model3d;
private List<AdminShopProductVariantDto> variants;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getCategoryName() {
return categoryName;
}
public void setCategoryName(String categoryName) {
this.categoryName = categoryName;
}
public String getCategorySlug() {
return categorySlug;
}
public void setCategorySlug(String categorySlug) {
this.categorySlug = categorySlug;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Integer getVariantCount() {
return variantCount;
}
public void setVariantCount(Integer variantCount) {
this.variantCount = variantCount;
}
public Integer getActiveVariantCount() {
return activeVariantCount;
}
public void setActiveVariantCount(Integer activeVariantCount) {
this.activeVariantCount = activeVariantCount;
}
public BigDecimal getPriceFromChf() {
return priceFromChf;
}
public void setPriceFromChf(BigDecimal priceFromChf) {
this.priceFromChf = priceFromChf;
}
public BigDecimal getPriceToChf() {
return priceToChf;
}
public void setPriceToChf(BigDecimal priceToChf) {
this.priceToChf = priceToChf;
}
public String getMediaUsageType() {
return mediaUsageType;
}
public void setMediaUsageType(String mediaUsageType) {
this.mediaUsageType = mediaUsageType;
}
public String getMediaUsageKey() {
return mediaUsageKey;
}
public void setMediaUsageKey(String mediaUsageKey) {
this.mediaUsageKey = mediaUsageKey;
}
public List<AdminMediaUsageDto> getMediaUsages() {
return mediaUsages;
}
public void setMediaUsages(List<AdminMediaUsageDto> mediaUsages) {
this.mediaUsages = mediaUsages;
}
public List<PublicMediaUsageDto> getImages() {
return images;
}
public void setImages(List<PublicMediaUsageDto> images) {
this.images = images;
}
public ShopProductModelDto getModel3d() {
return model3d;
}
public void setModel3d(ShopProductModelDto model3d) {
this.model3d = model3d;
}
public List<AdminShopProductVariantDto> getVariants() {
return variants;
}
public void setVariants(List<AdminShopProductVariantDto> variants) {
this.variants = variants;
}
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;
}
}

View File

@@ -0,0 +1,152 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminShopProductVariantDto {
private UUID id;
private String sku;
private String variantLabel;
private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex;
private String internalMaterialCode;
private BigDecimal priceChf;
private Boolean isDefault;
private Boolean isActive;
private Integer sortOrder;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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;
}
}

View File

@@ -0,0 +1,89 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class AdminTranslateShopProductRequest {
private UUID categoryId;
private String sourceLanguage;
private Boolean overwriteExisting;
private List<String> materialCodes;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public Boolean getOverwriteExisting() {
return overwriteExisting;
}
public void setOverwriteExisting(Boolean overwriteExisting) {
this.overwriteExisting = overwriteExisting;
}
public List<String> getMaterialCodes() {
return materialCodes;
}
public void setMaterialCodes(List<String> materialCodes) {
this.materialCodes = materialCodes;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,70 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
public class AdminTranslateShopProductResponse {
private String sourceLanguage;
private List<String> targetLanguages;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public List<String> getTargetLanguages() {
return targetLanguages;
}
public void setTargetLanguages(List<String> targetLanguages) {
this.targetLanguages = targetLanguages;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,40 @@
package com.printcalculator.dto;
public class AdminUpdateMediaAssetRequest {
private String title;
private String altText;
private String visibility;
private String status;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}

View File

@@ -0,0 +1,79 @@
package com.printcalculator.dto;
import java.util.UUID;
import java.util.Map;
public class AdminUpdateMediaUsageRequest {
private String usageType;
private String usageKey;
private UUID ownerId;
private UUID mediaAssetId;
private Integer sortOrder;
private Boolean isPrimary;
private Boolean isActive;
private Map<String, MediaTextTranslationDto> translations;
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Map<String, MediaTextTranslationDto> getTranslations() {
return translations;
}
public void setTranslations(Map<String, MediaTextTranslationDto> translations) {
this.translations = translations;
}
}

View File

@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId; private Long materialTypeId;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -0,0 +1,249 @@
package com.printcalculator.dto;
import java.util.UUID;
public class AdminUpsertShopCategoryRequest {
private UUID parentCategoryId;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isActive;
private Integer sortOrder;
public UUID getParentCategoryId() {
return parentCategoryId;
}
public void setParentCategoryId(UUID parentCategoryId) {
this.parentCategoryId = parentCategoryId;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@@ -0,0 +1,313 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public class AdminUpsertShopProductRequest {
private UUID categoryId;
private String slug;
private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String excerpt;
private String excerptIt;
private String excerptEn;
private String excerptDe;
private String excerptFr;
private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle;
private String ogDescription;
private Boolean indexable;
private Boolean isFeatured;
private Boolean isActive;
private Integer sortOrder;
private List<AdminUpsertShopProductVariantRequest> variants;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public List<AdminUpsertShopProductVariantRequest> getVariants() {
return variants;
}
public void setVariants(List<AdminUpsertShopProductVariantRequest> variants) {
this.variants = variants;
}
}

View File

@@ -0,0 +1,133 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public class AdminUpsertShopProductVariantRequest {
private UUID id;
private String sku;
private String variantLabel;
private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex;
private String internalMaterialCode;
private BigDecimal priceChf;
private Boolean isDefault;
private Boolean isActive;
private Integer sortOrder;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@@ -0,0 +1,22 @@
package com.printcalculator.dto;
public class MediaTextTranslationDto {
private String title;
private String altText;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
}

View File

@@ -15,6 +15,10 @@ public record OptionsResponse(
Long id, Long id,
String name, String name,
String colorName, String colorName,
String colorLabelIt,
String colorLabelEn,
String colorLabelDe,
String colorLabelFr,
String hexColor, String hexColor,
String finishType, String finishType,
Double stockSpools, Double stockSpools,

View File

@@ -8,6 +8,7 @@ import java.util.UUID;
public class OrderDto { public class OrderDto {
private UUID id; private UUID id;
private String orderNumber; private String orderNumber;
private String sourceType;
private String status; private String status;
private String paymentStatus; private String paymentStatus;
private String paymentMethod; private String paymentMethod;
@@ -45,6 +46,9 @@ public class OrderDto {
public String getOrderNumber() { return orderNumber; } public String getOrderNumber() { return orderNumber; }
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
public String getSourceType() { return sourceType; }
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
public String getStatus() { return status; } public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }

View File

@@ -5,12 +5,29 @@ import java.util.UUID;
public class OrderItemDto { public class OrderItemDto {
private UUID id; private UUID id;
private String itemType;
private String originalFilename; private String originalFilename;
private String displayName;
private String materialCode; private String materialCode;
private String colorCode; private String colorCode;
private Long filamentVariantId; private Long filamentVariantId;
private UUID shopProductId;
private UUID shopProductVariantId;
private String shopProductSlug;
private String shopProductName;
private String shopVariantLabel;
private String shopVariantColorName;
private String shopVariantColorLabelIt;
private String shopVariantColorLabelEn;
private String shopVariantColorLabelDe;
private String shopVariantColorLabelFr;
private String shopVariantColorHex;
private String filamentVariantDisplayName; private String filamentVariantDisplayName;
private String filamentColorName; private String filamentColorName;
private String filamentColorLabelIt;
private String filamentColorLabelEn;
private String filamentColorLabelDe;
private String filamentColorLabelFr;
private String filamentColorHex; private String filamentColorHex;
private String quality; private String quality;
private BigDecimal nozzleDiameterMm; private BigDecimal nozzleDiameterMm;
@@ -28,9 +45,15 @@ public class OrderItemDto {
public UUID getId() { return id; } public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; } public void setId(UUID id) { this.id = id; }
public String getItemType() { return itemType; }
public void setItemType(String itemType) { this.itemType = itemType; }
public String getOriginalFilename() { return originalFilename; } public String getOriginalFilename() { return originalFilename; }
public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; } public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getMaterialCode() { return materialCode; } public String getMaterialCode() { return materialCode; }
public void setMaterialCode(String materialCode) { this.materialCode = materialCode; } public void setMaterialCode(String materialCode) { this.materialCode = materialCode; }
@@ -40,12 +63,57 @@ public class OrderItemDto {
public Long getFilamentVariantId() { return filamentVariantId; } public Long getFilamentVariantId() { return filamentVariantId; }
public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; } public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; }
public UUID getShopProductId() { return shopProductId; }
public void setShopProductId(UUID shopProductId) { this.shopProductId = shopProductId; }
public UUID getShopProductVariantId() { return shopProductVariantId; }
public void setShopProductVariantId(UUID shopProductVariantId) { this.shopProductVariantId = shopProductVariantId; }
public String getShopProductSlug() { return shopProductSlug; }
public void setShopProductSlug(String shopProductSlug) { this.shopProductSlug = shopProductSlug; }
public String getShopProductName() { return shopProductName; }
public void setShopProductName(String shopProductName) { this.shopProductName = shopProductName; }
public String getShopVariantLabel() { return shopVariantLabel; }
public void setShopVariantLabel(String shopVariantLabel) { this.shopVariantLabel = shopVariantLabel; }
public String getShopVariantColorName() { return shopVariantColorName; }
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; }
public String getShopVariantColorLabelIt() { return shopVariantColorLabelIt; }
public void setShopVariantColorLabelIt(String shopVariantColorLabelIt) { this.shopVariantColorLabelIt = shopVariantColorLabelIt; }
public String getShopVariantColorLabelEn() { return shopVariantColorLabelEn; }
public void setShopVariantColorLabelEn(String shopVariantColorLabelEn) { this.shopVariantColorLabelEn = shopVariantColorLabelEn; }
public String getShopVariantColorLabelDe() { return shopVariantColorLabelDe; }
public void setShopVariantColorLabelDe(String shopVariantColorLabelDe) { this.shopVariantColorLabelDe = shopVariantColorLabelDe; }
public String getShopVariantColorLabelFr() { return shopVariantColorLabelFr; }
public void setShopVariantColorLabelFr(String shopVariantColorLabelFr) { this.shopVariantColorLabelFr = shopVariantColorLabelFr; }
public String getShopVariantColorHex() { return shopVariantColorHex; }
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; } public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; }
public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; } public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; }
public String getFilamentColorName() { return filamentColorName; } public String getFilamentColorName() { return filamentColorName; }
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; } public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
public String getFilamentColorLabelIt() { return filamentColorLabelIt; }
public void setFilamentColorLabelIt(String filamentColorLabelIt) { this.filamentColorLabelIt = filamentColorLabelIt; }
public String getFilamentColorLabelEn() { return filamentColorLabelEn; }
public void setFilamentColorLabelEn(String filamentColorLabelEn) { this.filamentColorLabelEn = filamentColorLabelEn; }
public String getFilamentColorLabelDe() { return filamentColorLabelDe; }
public void setFilamentColorLabelDe(String filamentColorLabelDe) { this.filamentColorLabelDe = filamentColorLabelDe; }
public String getFilamentColorLabelFr() { return filamentColorLabelFr; }
public void setFilamentColorLabelFr(String filamentColorLabelFr) { this.filamentColorLabelFr = filamentColorLabelFr; }
public String getFilamentColorHex() { return filamentColorHex; } public String getFilamentColorHex() { return filamentColorHex; }
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }

View File

@@ -0,0 +1,96 @@
package com.printcalculator.dto;
import java.util.UUID;
public class PublicMediaUsageDto {
private UUID mediaAssetId;
private String title;
private String altText;
private String usageType;
private String usageKey;
private Integer sortOrder;
private Boolean isPrimary;
private PublicMediaVariantDto thumb;
private PublicMediaVariantDto card;
private PublicMediaVariantDto hero;
public UUID getMediaAssetId() {
return mediaAssetId;
}
public void setMediaAssetId(UUID mediaAssetId) {
this.mediaAssetId = mediaAssetId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public PublicMediaVariantDto getThumb() {
return thumb;
}
public void setThumb(PublicMediaVariantDto thumb) {
this.thumb = thumb;
}
public PublicMediaVariantDto getCard() {
return card;
}
public void setCard(PublicMediaVariantDto card) {
this.card = card;
}
public PublicMediaVariantDto getHero() {
return hero;
}
public void setHero(PublicMediaVariantDto hero) {
this.hero = hero;
}
}

View File

@@ -0,0 +1,31 @@
package com.printcalculator.dto;
public class PublicMediaVariantDto {
private String avifUrl;
private String webpUrl;
private String jpegUrl;
public String getAvifUrl() {
return avifUrl;
}
public void setAvifUrl(String avifUrl) {
this.avifUrl = avifUrl;
}
public String getWebpUrl() {
return webpUrl;
}
public void setWebpUrl(String webpUrl) {
this.webpUrl = webpUrl;
}
public String getJpegUrl() {
return jpegUrl;
}
public void setJpegUrl(String jpegUrl) {
this.jpegUrl = jpegUrl;
}
}

View File

@@ -0,0 +1,30 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
public class ShopCartAddItemRequest {
@NotNull
private UUID shopProductVariantId;
@Min(1)
private Integer quantity = 1;
public UUID getShopProductVariantId() {
return shopProductVariantId;
}
public void setShopProductVariantId(UUID shopProductVariantId) {
this.shopProductVariantId = shopProductVariantId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,18 @@
package com.printcalculator.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class ShopCartUpdateItemRequest {
@NotNull
@Min(1)
private Integer quantity;
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}

View File

@@ -0,0 +1,23 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public record ShopCategoryDetailDto(
UUID id,
String slug,
String name,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Integer sortOrder,
Integer productCount,
List<ShopCategoryRefDto> breadcrumbs,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
List<ShopCategoryTreeDto> children
) {
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.dto;
import java.util.UUID;
public record ShopCategoryRefDto(
UUID id,
String slug,
String name
) {
}

View File

@@ -0,0 +1,22 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.UUID;
public record ShopCategoryTreeDto(
UUID id,
UUID parentCategoryId,
String slug,
String name,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Integer sortOrder,
Integer productCount,
PublicMediaUsageDto primaryImage,
List<ShopCategoryTreeDto> children
) {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.dto;
import java.util.List;
public record ShopProductCatalogResponseDto(
String categorySlug,
Boolean featuredOnly,
ShopCategoryDetailDto category,
List<ShopProductSummaryDto> products
) {
}

View File

@@ -0,0 +1,33 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record ShopProductDetailDto(
UUID id,
String slug,
String name,
String excerpt,
String description,
String seoTitle,
String seoDescription,
String ogTitle,
String ogDescription,
Boolean indexable,
Boolean isFeatured,
Integer sortOrder,
ShopCategoryRefDto category,
List<ShopCategoryRefDto> breadcrumbs,
BigDecimal priceFromChf,
BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant,
List<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -0,0 +1,14 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
public record ShopProductModelDto(
String url,
String originalFilename,
String mimeType,
Long fileSizeBytes,
BigDecimal boundingBoxXMm,
BigDecimal boundingBoxYMm,
BigDecimal boundingBoxZMm
) {
}

View File

@@ -0,0 +1,23 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.Map;
import java.util.UUID;
public record ShopProductSummaryDto(
UUID id,
String slug,
String name,
String excerpt,
Boolean isFeatured,
Integer sortOrder,
ShopCategoryRefDto category,
BigDecimal priceFromChf,
BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant,
PublicMediaUsageDto primaryImage,
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.dto;
import java.math.BigDecimal;
import java.util.UUID;
public record ShopProductVariantOptionDto(
UUID id,
String sku,
String variantLabel,
String colorName,
String colorLabel,
String colorHex,
BigDecimal priceChf,
Boolean isDefault
) {
}

View File

@@ -24,6 +24,18 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; private String colorName;
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -93,6 +105,38 @@ public class FilamentVariant {
this.colorName = colorName; this.colorName = colorName;
} }
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -173,4 +217,60 @@ public class FilamentVariant {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
} }

View File

@@ -0,0 +1,177 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_asset", indexes = {
@Index(name = "ix_media_asset_status_visibility_created_at", columnList = "status, visibility, created_at")
})
public class MediaAsset {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_asset_id", nullable = false)
private UUID id;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true)
private String storageKey;
@Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "file_size_bytes", nullable = false)
private Long fileSizeBytes;
@Column(name = "sha256_hex", nullable = false, length = Integer.MAX_VALUE)
private String sha256Hex;
@Column(name = "width_px")
private Integer widthPx;
@Column(name = "height_px")
private Integer heightPx;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status;
@Column(name = "visibility", nullable = false, length = Integer.MAX_VALUE)
private String visibility;
@Column(name = "title", length = Integer.MAX_VALUE)
private String title;
@Column(name = "alt_text", length = Integer.MAX_VALUE)
private String altText;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
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 getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
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 String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getVisibility() {
return visibility;
}
public void setVisibility(String visibility) {
this.visibility = visibility;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getAltText() {
return altText;
}
public void setAltText(String altText) {
this.altText = altText;
}
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;
}
}

View File

@@ -0,0 +1,273 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_usage", indexes = {
@Index(name = "ix_media_usage_scope", columnList = "usage_type, usage_key, is_active, sort_order")
})
public class MediaUsage {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_usage_id", nullable = false)
private UUID id;
@Column(name = "usage_type", nullable = false, length = Integer.MAX_VALUE)
private String usageType;
@Column(name = "usage_key", nullable = false, length = Integer.MAX_VALUE)
private String usageKey;
@Column(name = "owner_id")
private UUID ownerId;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "media_asset_id", nullable = false)
private MediaAsset mediaAsset;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("false")
@Column(name = "is_primary", nullable = false)
private Boolean isPrimary;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "title_it", length = Integer.MAX_VALUE)
private String titleIt;
@Column(name = "title_en", length = Integer.MAX_VALUE)
private String titleEn;
@Column(name = "title_de", length = Integer.MAX_VALUE)
private String titleDe;
@Column(name = "title_fr", length = Integer.MAX_VALUE)
private String titleFr;
@Column(name = "alt_text_it", length = Integer.MAX_VALUE)
private String altTextIt;
@Column(name = "alt_text_en", length = Integer.MAX_VALUE)
private String altTextEn;
@Column(name = "alt_text_de", length = Integer.MAX_VALUE)
private String altTextDe;
@Column(name = "alt_text_fr", length = Integer.MAX_VALUE)
private String altTextFr;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getUsageType() {
return usageType;
}
public void setUsageType(String usageType) {
this.usageType = usageType;
}
public String getUsageKey() {
return usageKey;
}
public void setUsageKey(String usageKey) {
this.usageKey = usageKey;
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
public MediaAsset getMediaAsset() {
return mediaAsset;
}
public void setMediaAsset(MediaAsset mediaAsset) {
this.mediaAsset = mediaAsset;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getIsPrimary() {
return isPrimary;
}
public void setIsPrimary(Boolean primary) {
isPrimary = primary;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public String getTitleIt() {
return titleIt;
}
public void setTitleIt(String titleIt) {
this.titleIt = titleIt;
}
public String getTitleEn() {
return titleEn;
}
public void setTitleEn(String titleEn) {
this.titleEn = titleEn;
}
public String getTitleDe() {
return titleDe;
}
public void setTitleDe(String titleDe) {
this.titleDe = titleDe;
}
public String getTitleFr() {
return titleFr;
}
public void setTitleFr(String titleFr) {
this.titleFr = titleFr;
}
public String getAltTextIt() {
return altTextIt;
}
public void setAltTextIt(String altTextIt) {
this.altTextIt = altTextIt;
}
public String getAltTextEn() {
return altTextEn;
}
public void setAltTextEn(String altTextEn) {
this.altTextEn = altTextEn;
}
public String getAltTextDe() {
return altTextDe;
}
public void setAltTextDe(String altTextDe) {
this.altTextDe = altTextDe;
}
public String getAltTextFr() {
return altTextFr;
}
public void setAltTextFr(String altTextFr) {
this.altTextFr = altTextFr;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public String getTitleForLanguage(String language) {
if (language == null) {
return null;
}
return switch (language.trim().toLowerCase()) {
case "it" -> titleIt;
case "en" -> titleEn;
case "de" -> titleDe;
case "fr" -> titleFr;
default -> null;
};
}
public void setTitleForLanguage(String language, String value) {
if (language == null) {
return;
}
switch (language.trim().toLowerCase()) {
case "it" -> titleIt = value;
case "en" -> titleEn = value;
case "de" -> titleDe = value;
case "fr" -> titleFr = value;
default -> {
}
}
}
public String getAltTextForLanguage(String language) {
if (language == null) {
return null;
}
return switch (language.trim().toLowerCase()) {
case "it" -> altTextIt;
case "en" -> altTextEn;
case "de" -> altTextDe;
case "fr" -> altTextFr;
default -> null;
};
}
public void setAltTextForLanguage(String language, String value) {
if (language == null) {
return;
}
switch (language.trim().toLowerCase()) {
case "it" -> altTextIt = value;
case "en" -> altTextEn = value;
case "de" -> altTextDe = value;
case "fr" -> altTextFr = value;
default -> {
}
}
}
}

View File

@@ -0,0 +1,154 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "media_variant", indexes = {
@Index(name = "ix_media_variant_asset", columnList = "media_asset_id")
}, uniqueConstraints = {
@UniqueConstraint(name = "uq_media_variant_asset_name_format", columnNames = {"media_asset_id", "variant_name", "format"})
})
public class MediaVariant {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "media_variant_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "media_asset_id", nullable = false)
private MediaAsset mediaAsset;
@Column(name = "variant_name", nullable = false, length = Integer.MAX_VALUE)
private String variantName;
@Column(name = "format", nullable = false, length = Integer.MAX_VALUE)
private String format;
@Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true)
private String storageKey;
@Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "width_px", nullable = false)
private Integer widthPx;
@Column(name = "height_px", nullable = false)
private Integer heightPx;
@Column(name = "file_size_bytes", nullable = false)
private Long fileSizeBytes;
@ColumnDefault("true")
@Column(name = "is_generated", nullable = false)
private Boolean isGenerated;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public MediaAsset getMediaAsset() {
return mediaAsset;
}
public void setMediaAsset(MediaAsset mediaAsset) {
this.mediaAsset = mediaAsset;
}
public String getVariantName() {
return variantName;
}
public void setVariantName(String variantName) {
this.variantName = variantName;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getStorageKey() {
return storageKey;
}
public void setStorageKey(String storageKey) {
this.storageKey = storageKey;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public Integer getWidthPx() {
return widthPx;
}
public void setWidthPx(Integer widthPx) {
this.widthPx = widthPx;
}
public Integer getHeightPx() {
return heightPx;
}
public void setHeightPx(Integer heightPx) {
this.heightPx = heightPx;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public Boolean getIsGenerated() {
return isGenerated;
}
public void setIsGenerated(Boolean generated) {
isGenerated = generated;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -20,6 +20,10 @@ public class Order {
@JoinColumn(name = "source_quote_session_id") @JoinColumn(name = "source_quote_session_id")
private QuoteSession sourceQuoteSession; private QuoteSession sourceQuoteSession;
@ColumnDefault("'CALCULATOR'")
@Column(name = "source_type", nullable = false, length = Integer.MAX_VALUE)
private String sourceType;
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@@ -151,6 +155,34 @@ public class Order {
@Column(name = "paid_at") @Column(name = "paid_at")
private OffsetDateTime paidAt; private OffsetDateTime paidAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (shippingSameAsBilling == null) {
shippingSameAsBilling = true;
}
if (sourceType == null || sourceType.isBlank()) {
sourceType = "CALCULATOR";
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (shippingSameAsBilling == null) {
shippingSameAsBilling = true;
}
if (sourceType == null || sourceType.isBlank()) {
sourceType = "CALCULATOR";
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -177,6 +209,14 @@ public class Order {
this.sourceQuoteSession = sourceQuoteSession; this.sourceQuoteSession = sourceQuoteSession;
} }
public String getSourceType() {
return sourceType;
}
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
public String getStatus() { public String getStatus() {
return status; return status;
} }

View File

@@ -23,9 +23,16 @@ public class OrderItem {
@JoinColumn(name = "order_id", nullable = false) @JoinColumn(name = "order_id", nullable = false)
private Order order; private Order order;
@ColumnDefault("'PRINT_FILE'")
@Column(name = "item_type", nullable = false, length = Integer.MAX_VALUE)
private String itemType;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename; private String originalFilename;
@Column(name = "display_name", length = Integer.MAX_VALUE)
private String displayName;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath; private String storedRelativePath;
@@ -66,6 +73,29 @@ public class OrderItem {
@JoinColumn(name = "filament_variant_id") @JoinColumn(name = "filament_variant_id")
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_id")
private ShopProduct shopProduct;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_variant_id")
private ShopProductVariant shopProductVariant;
@Column(name = "shop_product_slug", length = Integer.MAX_VALUE)
private String shopProductSlug;
@Column(name = "shop_product_name", length = Integer.MAX_VALUE)
private String shopProductName;
@Column(name = "shop_variant_label", length = Integer.MAX_VALUE)
private String shopVariantLabel;
@Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE)
private String shopVariantColorName;
@Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE)
private String shopVariantColorHex;
@Column(name = "color_code", length = Integer.MAX_VALUE) @Column(name = "color_code", length = Integer.MAX_VALUE)
private String colorCode; private String colorCode;
@@ -106,6 +136,14 @@ public class OrderItem {
if (quantity == null) { if (quantity == null) {
quantity = 1; quantity = 1;
} }
if (itemType == null || itemType.isBlank()) {
itemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
} }
public UUID getId() { public UUID getId() {
@@ -124,6 +162,14 @@ public class OrderItem {
this.order = order; this.order = order;
} }
public String getItemType() {
return itemType;
}
public void setItemType(String itemType) {
this.itemType = itemType;
}
public String getOriginalFilename() { public String getOriginalFilename() {
return originalFilename; return originalFilename;
} }
@@ -132,6 +178,14 @@ public class OrderItem {
this.originalFilename = originalFilename; this.originalFilename = originalFilename;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getStoredRelativePath() { public String getStoredRelativePath() {
return storedRelativePath; return storedRelativePath;
} }
@@ -236,6 +290,62 @@ public class OrderItem {
this.filamentVariant = filamentVariant; this.filamentVariant = filamentVariant;
} }
public ShopProduct getShopProduct() {
return shopProduct;
}
public void setShopProduct(ShopProduct shopProduct) {
this.shopProduct = shopProduct;
}
public ShopProductVariant getShopProductVariant() {
return shopProductVariant;
}
public void setShopProductVariant(ShopProductVariant shopProductVariant) {
this.shopProductVariant = shopProductVariant;
}
public String getShopProductSlug() {
return shopProductSlug;
}
public void setShopProductSlug(String shopProductSlug) {
this.shopProductSlug = shopProductSlug;
}
public String getShopProductName() {
return shopProductName;
}
public void setShopProductName(String shopProductName) {
this.shopProductName = shopProductName;
}
public String getShopVariantLabel() {
return shopVariantLabel;
}
public void setShopVariantLabel(String shopVariantLabel) {
this.shopVariantLabel = shopVariantLabel;
}
public String getShopVariantColorName() {
return shopVariantColorName;
}
public void setShopVariantColorName(String shopVariantColorName) {
this.shopVariantColorName = shopVariantColorName;
}
public String getShopVariantColorHex() {
return shopVariantColorHex;
}
public void setShopVariantColorHex(String shopVariantColorHex) {
this.shopVariantColorHex = shopVariantColorHex;
}
public String getColorCode() { public String getColorCode() {
return colorCode; return colorCode;
} }

View File

@@ -30,9 +30,16 @@ public class QuoteLineItem {
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@ColumnDefault("'PRINT_FILE'")
@Column(name = "line_item_type", nullable = false, length = Integer.MAX_VALUE)
private String lineItemType;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename; private String originalFilename;
@Column(name = "display_name", length = Integer.MAX_VALUE)
private String displayName;
@ColumnDefault("1") @ColumnDefault("1")
@Column(name = "quantity", nullable = false) @Column(name = "quantity", nullable = false)
private Integer quantity; private Integer quantity;
@@ -45,6 +52,31 @@ public class QuoteLineItem {
@com.fasterxml.jackson.annotation.JsonIgnore @com.fasterxml.jackson.annotation.JsonIgnore
private FilamentVariant filamentVariant; private FilamentVariant filamentVariant;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private ShopProduct shopProduct;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_product_variant_id")
@com.fasterxml.jackson.annotation.JsonIgnore
private ShopProductVariant shopProductVariant;
@Column(name = "shop_product_slug", length = Integer.MAX_VALUE)
private String shopProductSlug;
@Column(name = "shop_product_name", length = Integer.MAX_VALUE)
private String shopProductName;
@Column(name = "shop_variant_label", length = Integer.MAX_VALUE)
private String shopVariantLabel;
@Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE)
private String shopVariantColorName;
@Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE)
private String shopVariantColorHex;
@Column(name = "material_code", length = Integer.MAX_VALUE) @Column(name = "material_code", length = Integer.MAX_VALUE)
private String materialCode; private String materialCode;
@@ -102,6 +134,41 @@ public class QuoteLineItem {
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (quantity == null) {
quantity = 1;
}
if (lineItemType == null || lineItemType.isBlank()) {
lineItemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (lineItemType == null || lineItemType.isBlank()) {
lineItemType = "PRINT_FILE";
}
if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) {
displayName = originalFilename;
} else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) {
displayName = shopProductName;
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -126,6 +193,14 @@ public class QuoteLineItem {
this.status = status; this.status = status;
} }
public String getLineItemType() {
return lineItemType;
}
public void setLineItemType(String lineItemType) {
this.lineItemType = lineItemType;
}
public String getOriginalFilename() { public String getOriginalFilename() {
return originalFilename; return originalFilename;
} }
@@ -134,6 +209,14 @@ public class QuoteLineItem {
this.originalFilename = originalFilename; this.originalFilename = originalFilename;
} }
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Integer getQuantity() { public Integer getQuantity() {
return quantity; return quantity;
} }
@@ -158,6 +241,62 @@ public class QuoteLineItem {
this.filamentVariant = filamentVariant; this.filamentVariant = filamentVariant;
} }
public ShopProduct getShopProduct() {
return shopProduct;
}
public void setShopProduct(ShopProduct shopProduct) {
this.shopProduct = shopProduct;
}
public ShopProductVariant getShopProductVariant() {
return shopProductVariant;
}
public void setShopProductVariant(ShopProductVariant shopProductVariant) {
this.shopProductVariant = shopProductVariant;
}
public String getShopProductSlug() {
return shopProductSlug;
}
public void setShopProductSlug(String shopProductSlug) {
this.shopProductSlug = shopProductSlug;
}
public String getShopProductName() {
return shopProductName;
}
public void setShopProductName(String shopProductName) {
this.shopProductName = shopProductName;
}
public String getShopVariantLabel() {
return shopVariantLabel;
}
public void setShopVariantLabel(String shopVariantLabel) {
this.shopVariantLabel = shopVariantLabel;
}
public String getShopVariantColorName() {
return shopVariantColorName;
}
public void setShopVariantColorName(String shopVariantColorName) {
this.shopVariantColorName = shopVariantColorName;
}
public String getShopVariantColorHex() {
return shopVariantColorHex;
}
public void setShopVariantColorHex(String shopVariantColorHex) {
this.shopVariantColorHex = shopVariantColorHex;
}
public String getMaterialCode() { public String getMaterialCode() {
return materialCode; return materialCode;
} }

View File

@@ -22,6 +22,10 @@ public class QuoteSession {
@Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
private String status; private String status;
@ColumnDefault("'PRINT_QUOTE'")
@Column(name = "session_type", nullable = false, length = Integer.MAX_VALUE)
private String sessionType;
@Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE) @Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE)
private String pricingVersion; private String pricingVersion;
@@ -70,6 +74,19 @@ public class QuoteSession {
@Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2) @Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2)
private BigDecimal cadHourlyRateChf; private BigDecimal cadHourlyRateChf;
@PrePersist
private void onCreate() {
if (sessionType == null || sessionType.isBlank()) {
sessionType = "PRINT_QUOTE";
}
if (supportsEnabled == null) {
supportsEnabled = false;
}
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -86,6 +103,14 @@ public class QuoteSession {
this.status = status; this.status = status;
} }
public String getSessionType() {
return sessionType;
}
public void setSessionType(String sessionType) {
this.sessionType = sessionType;
}
public String getPricingVersion() { public String getPricingVersion() {
return pricingVersion; return pricingVersion;
} }

View File

@@ -0,0 +1,505 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "shop_category", indexes = {
@Index(name = "ix_shop_category_parent_sort", columnList = "parent_category_id, sort_order"),
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
})
public class ShopCategory {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_category_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_category_id")
private ShopCategory parentCategory;
@Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE)
private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name;
@Column(name = "name_it", length = Integer.MAX_VALUE)
private String nameIt;
@Column(name = "name_en", length = Integer.MAX_VALUE)
private String nameEn;
@Column(name = "name_de", length = Integer.MAX_VALUE)
private String nameDe;
@Column(name = "name_fr", length = Integer.MAX_VALUE)
private String nameFr;
@Column(name = "description", length = Integer.MAX_VALUE)
private String description;
@Column(name = "description_it", length = Integer.MAX_VALUE)
private String descriptionIt;
@Column(name = "description_en", length = Integer.MAX_VALUE)
private String descriptionEn;
@Column(name = "description_de", length = Integer.MAX_VALUE)
private String descriptionDe;
@Column(name = "description_fr", length = Integer.MAX_VALUE)
private String descriptionFr;
@Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle;
@Column(name = "og_description", length = Integer.MAX_VALUE)
private String ogDescription;
@ColumnDefault("true")
@Column(name = "indexable", nullable = false)
private Boolean indexable;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (indexable == null) {
indexable = true;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (indexable == null) {
indexable = true;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopCategory getParentCategory() {
return parentCategory;
}
public void setParentCategory(ShopCategory parentCategory) {
this.parentCategory = parentCategory;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getNameForLanguage(String language) {
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
}
public void setNameForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> nameIt = value;
case "en" -> nameEn = value;
case "de" -> nameDe = value;
case "fr" -> nameFr = value;
default -> {
}
}
}
public String getDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
}
public void setDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> descriptionIt = value;
case "en" -> descriptionEn = value;
case "de" -> descriptionDe = value;
case "fr" -> descriptionFr = value;
default -> {
}
}
}
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,593 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "shop_product", indexes = {
@Index(name = "ix_shop_product_category_active_sort", columnList = "shop_category_id, is_active, sort_order"),
@Index(name = "ix_shop_product_featured_sort", columnList = "is_featured, is_active, sort_order")
})
public class ShopProduct {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_category_id", nullable = false)
private ShopCategory category;
@Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE)
private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name;
@Column(name = "name_it", length = Integer.MAX_VALUE)
private String nameIt;
@Column(name = "name_en", length = Integer.MAX_VALUE)
private String nameEn;
@Column(name = "name_de", length = Integer.MAX_VALUE)
private String nameDe;
@Column(name = "name_fr", length = Integer.MAX_VALUE)
private String nameFr;
@Column(name = "excerpt", length = Integer.MAX_VALUE)
private String excerpt;
@Column(name = "excerpt_it", length = Integer.MAX_VALUE)
private String excerptIt;
@Column(name = "excerpt_en", length = Integer.MAX_VALUE)
private String excerptEn;
@Column(name = "excerpt_de", length = Integer.MAX_VALUE)
private String excerptDe;
@Column(name = "excerpt_fr", length = Integer.MAX_VALUE)
private String excerptFr;
@Column(name = "description", length = Integer.MAX_VALUE)
private String description;
@Column(name = "description_it", length = Integer.MAX_VALUE)
private String descriptionIt;
@Column(name = "description_en", length = Integer.MAX_VALUE)
private String descriptionEn;
@Column(name = "description_de", length = Integer.MAX_VALUE)
private String descriptionDe;
@Column(name = "description_fr", length = Integer.MAX_VALUE)
private String descriptionFr;
@Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle;
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
private String seoTitleIt;
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
private String seoTitleEn;
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
private String seoTitleDe;
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
private String seoTitleFr;
@Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription;
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
private String seoDescriptionIt;
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
private String seoDescriptionEn;
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
private String seoDescriptionDe;
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
private String seoDescriptionFr;
@Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle;
@Column(name = "og_description", length = Integer.MAX_VALUE)
private String ogDescription;
@ColumnDefault("true")
@Column(name = "indexable", nullable = false)
private Boolean indexable;
@ColumnDefault("false")
@Column(name = "is_featured", nullable = false)
private Boolean isFeatured;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (indexable == null) {
indexable = true;
}
if (isFeatured == null) {
isFeatured = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (indexable == null) {
indexable = true;
}
if (isFeatured == null) {
isFeatured = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopCategory getCategory() {
return category;
}
public void setCategory(ShopCategory category) {
this.category = category;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameIt() {
return nameIt;
}
public void setNameIt(String nameIt) {
this.nameIt = nameIt;
}
public String getNameEn() {
return nameEn;
}
public void setNameEn(String nameEn) {
this.nameEn = nameEn;
}
public String getNameDe() {
return nameDe;
}
public void setNameDe(String nameDe) {
this.nameDe = nameDe;
}
public String getNameFr() {
return nameFr;
}
public void setNameFr(String nameFr) {
this.nameFr = nameFr;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getExcerptIt() {
return excerptIt;
}
public void setExcerptIt(String excerptIt) {
this.excerptIt = excerptIt;
}
public String getExcerptEn() {
return excerptEn;
}
public void setExcerptEn(String excerptEn) {
this.excerptEn = excerptEn;
}
public String getExcerptDe() {
return excerptDe;
}
public void setExcerptDe(String excerptDe) {
this.excerptDe = excerptDe;
}
public String getExcerptFr() {
return excerptFr;
}
public void setExcerptFr(String excerptFr) {
this.excerptFr = excerptFr;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescriptionIt() {
return descriptionIt;
}
public void setDescriptionIt(String descriptionIt) {
this.descriptionIt = descriptionIt;
}
public String getDescriptionEn() {
return descriptionEn;
}
public void setDescriptionEn(String descriptionEn) {
this.descriptionEn = descriptionEn;
}
public String getDescriptionDe() {
return descriptionDe;
}
public void setDescriptionDe(String descriptionDe) {
this.descriptionDe = descriptionDe;
}
public String getDescriptionFr() {
return descriptionFr;
}
public void setDescriptionFr(String descriptionFr) {
this.descriptionFr = descriptionFr;
}
public String getSeoTitle() {
return seoTitle;
}
public void setSeoTitle(String seoTitle) {
this.seoTitle = seoTitle;
}
public String getSeoDescription() {
return seoDescription;
}
public void setSeoDescription(String seoDescription) {
this.seoDescription = seoDescription;
}
public String getSeoTitleIt() {
return seoTitleIt;
}
public void setSeoTitleIt(String seoTitleIt) {
this.seoTitleIt = seoTitleIt;
}
public String getSeoTitleEn() {
return seoTitleEn;
}
public void setSeoTitleEn(String seoTitleEn) {
this.seoTitleEn = seoTitleEn;
}
public String getSeoTitleDe() {
return seoTitleDe;
}
public void setSeoTitleDe(String seoTitleDe) {
this.seoTitleDe = seoTitleDe;
}
public String getSeoTitleFr() {
return seoTitleFr;
}
public void setSeoTitleFr(String seoTitleFr) {
this.seoTitleFr = seoTitleFr;
}
public String getSeoDescriptionIt() {
return seoDescriptionIt;
}
public void setSeoDescriptionIt(String seoDescriptionIt) {
this.seoDescriptionIt = seoDescriptionIt;
}
public String getSeoDescriptionEn() {
return seoDescriptionEn;
}
public void setSeoDescriptionEn(String seoDescriptionEn) {
this.seoDescriptionEn = seoDescriptionEn;
}
public String getSeoDescriptionDe() {
return seoDescriptionDe;
}
public void setSeoDescriptionDe(String seoDescriptionDe) {
this.seoDescriptionDe = seoDescriptionDe;
}
public String getSeoDescriptionFr() {
return seoDescriptionFr;
}
public void setSeoDescriptionFr(String seoDescriptionFr) {
this.seoDescriptionFr = seoDescriptionFr;
}
public String getOgTitle() {
return ogTitle;
}
public void setOgTitle(String ogTitle) {
this.ogTitle = ogTitle;
}
public String getOgDescription() {
return ogDescription;
}
public void setOgDescription(String ogDescription) {
this.ogDescription = ogDescription;
}
public Boolean getIndexable() {
return indexable;
}
public void setIndexable(Boolean indexable) {
this.indexable = indexable;
}
public Boolean getIsFeatured() {
return isFeatured;
}
public void setIsFeatured(Boolean featured) {
isFeatured = featured;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getNameForLanguage(String language) {
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
}
public void setNameForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> nameIt = value;
case "en" -> nameEn = value;
case "de" -> nameDe = value;
case "fr" -> nameFr = value;
default -> {
}
}
}
public String getExcerptForLanguage(String language) {
return resolveLocalizedValue(language, excerpt, excerptIt, excerptEn, excerptDe, excerptFr);
}
public void setExcerptForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> excerptIt = value;
case "en" -> excerptEn = value;
case "de" -> excerptDe = value;
case "fr" -> excerptFr = value;
default -> {
}
}
}
public String getDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
}
public void setDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> descriptionIt = value;
case "en" -> descriptionEn = value;
case "de" -> descriptionDe = value;
case "fr" -> descriptionFr = value;
default -> {
}
}
}
public String getSeoTitleForLanguage(String language) {
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
}
public void setSeoTitleForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoTitleIt = value;
case "en" -> seoTitleEn = value;
case "de" -> seoTitleDe = value;
case "fr" -> seoTitleFr = value;
default -> {
}
}
}
public String getSeoDescriptionForLanguage(String language) {
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
}
public void setSeoDescriptionForLanguage(String language, String value) {
switch (normalizeLanguage(language)) {
case "it" -> seoDescriptionIt = value;
case "en" -> seoDescriptionEn = value;
case "de" -> seoDescriptionDe = value;
case "fr" -> seoDescriptionFr = value;
default -> {
}
}
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,189 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "shop_product_model_asset", indexes = {
@Index(name = "ix_shop_product_model_asset_product", columnList = "shop_product_id")
})
public class ShopProductModelAsset {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_model_asset_id", nullable = false)
private UUID id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_product_id", nullable = false, unique = true)
private ShopProduct product;
@Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE)
private String originalFilename;
@Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE)
private String storedRelativePath;
@Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE)
private String storedFilename;
@Column(name = "file_size_bytes")
private Long fileSizeBytes;
@Column(name = "mime_type", length = Integer.MAX_VALUE)
private String mimeType;
@Column(name = "sha256_hex", length = Integer.MAX_VALUE)
private String sha256Hex;
@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;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopProduct getProduct() {
return product;
}
public void setProduct(ShopProduct product) {
this.product = product;
}
public String getOriginalFilename() {
return originalFilename;
}
public void setOriginalFilename(String originalFilename) {
this.originalFilename = originalFilename;
}
public String getStoredRelativePath() {
return storedRelativePath;
}
public void setStoredRelativePath(String storedRelativePath) {
this.storedRelativePath = storedRelativePath;
}
public String getStoredFilename() {
return storedFilename;
}
public void setStoredFilename(String storedFilename) {
this.storedFilename = storedFilename;
}
public Long getFileSizeBytes() {
return fileSizeBytes;
}
public void setFileSizeBytes(Long fileSizeBytes) {
this.fileSizeBytes = fileSizeBytes;
}
public String getMimeType() {
return mimeType;
}
public void setMimeType(String mimeType) {
this.mimeType = mimeType;
}
public String getSha256Hex() {
return sha256Hex;
}
public void setSha256Hex(String sha256Hex) {
this.sha256Hex = sha256Hex;
}
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 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;
}
}

View File

@@ -0,0 +1,318 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "shop_product_variant", indexes = {
@Index(name = "ix_shop_product_variant_product_active_sort", columnList = "shop_product_id, is_active, sort_order"),
@Index(name = "ix_shop_product_variant_sku", columnList = "sku")
})
public class ShopProductVariant {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_product_variant_id", nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "shop_product_id", nullable = false)
private ShopProduct product;
@Column(name = "sku", unique = true, length = Integer.MAX_VALUE)
private String sku;
@Column(name = "variant_label", nullable = false, length = Integer.MAX_VALUE)
private String variantLabel;
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName;
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
private String colorLabelIt;
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
private String colorLabelEn;
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
private String colorLabelDe;
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
private String colorLabelFr;
@Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex;
@Column(name = "internal_material_code", nullable = false, length = Integer.MAX_VALUE)
private String internalMaterialCode;
@ColumnDefault("0.00")
@Column(name = "price_chf", nullable = false, precision = 12, scale = 2)
private BigDecimal priceChf;
@ColumnDefault("false")
@Column(name = "is_default", nullable = false)
private Boolean isDefault;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("0")
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
private void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
if (priceChf == null) {
priceChf = BigDecimal.ZERO;
}
if (isDefault == null) {
isDefault = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
@PreUpdate
private void onUpdate() {
updatedAt = OffsetDateTime.now();
if (priceChf == null) {
priceChf = BigDecimal.ZERO;
}
if (isDefault == null) {
isDefault = false;
}
if (isActive == null) {
isActive = true;
}
if (sortOrder == null) {
sortOrder = 0;
}
}
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public ShopProduct getProduct() {
return product;
}
public void setProduct(ShopProduct product) {
this.product = product;
}
public String getSku() {
return sku;
}
public void setSku(String sku) {
this.sku = sku;
}
public String getVariantLabel() {
return variantLabel;
}
public void setVariantLabel(String variantLabel) {
this.variantLabel = variantLabel;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public String getColorLabelIt() {
return colorLabelIt;
}
public void setColorLabelIt(String colorLabelIt) {
this.colorLabelIt = colorLabelIt;
}
public String getColorLabelEn() {
return colorLabelEn;
}
public void setColorLabelEn(String colorLabelEn) {
this.colorLabelEn = colorLabelEn;
}
public String getColorLabelDe() {
return colorLabelDe;
}
public void setColorLabelDe(String colorLabelDe) {
this.colorLabelDe = colorLabelDe;
}
public String getColorLabelFr() {
return colorLabelFr;
}
public void setColorLabelFr(String colorLabelFr) {
this.colorLabelFr = colorLabelFr;
}
public String getColorHex() {
return colorHex;
}
public void setColorHex(String colorHex) {
this.colorHex = colorHex;
}
public String getInternalMaterialCode() {
return internalMaterialCode;
}
public void setInternalMaterialCode(String internalMaterialCode) {
this.internalMaterialCode = internalMaterialCode;
}
public BigDecimal getPriceChf() {
return priceChf;
}
public void setPriceChf(BigDecimal priceChf) {
this.priceChf = priceChf;
}
public Boolean getIsDefault() {
return isDefault;
}
public void setIsDefault(Boolean aDefault) {
isDefault = aDefault;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean active) {
isActive = active;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
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 String getColorLabelForLanguage(String language) {
return resolveLocalizedValue(
language,
colorName,
colorLabelIt,
colorLabelEn,
colorLabelDe,
colorLabelFr
);
}
private String resolveLocalizedValue(String language,
String fallback,
String valueIt,
String valueEn,
String valueDe,
String valueFr) {
String normalizedLanguage = normalizeLanguage(language);
String preferred = switch (normalizedLanguage) {
case "it" -> valueIt;
case "en" -> valueEn;
case "de" -> valueDe;
case "fr" -> valueFr;
default -> null;
};
String resolved = firstNonBlank(preferred, fallback);
if (resolved != null) {
return resolved;
}
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase();
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -223,10 +223,15 @@ public class OrderEmailListener {
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
); );
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
return templateData; return templateData;
} }
private String buildLogoUrl() {
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) { private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) { return switch (language) {
case "en" -> { case "en" -> {

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface MediaAssetRepository extends JpaRepository<MediaAsset, UUID> {
List<MediaAsset> findAllByOrderByCreatedAtDesc();
}

View File

@@ -0,0 +1,43 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaUsage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public interface MediaUsageRepository extends JpaRepository<MediaUsage, UUID> {
List<MediaUsage> findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId);
List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
List<MediaUsage> findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType,
String usageKey);
List<MediaUsage> findByUsageTypeAndUsageKeyOrderBySortOrderAscCreatedAtAsc(String usageType,
String usageKey);
@Query("""
select usage from MediaUsage usage
where usage.usageType = :usageType
and usage.usageKey in :usageKeys
and usage.isActive = true
order by usage.usageKey asc, usage.sortOrder asc, usage.createdAt asc
""")
List<MediaUsage> findActiveByUsageTypeAndUsageKeys(@Param("usageType") String usageType,
@Param("usageKeys") Collection<String> usageKeys);
@Query("""
select usage from MediaUsage usage
where usage.usageType = :usageType
and usage.usageKey = :usageKey
and ((:ownerId is null and usage.ownerId is null) or usage.ownerId = :ownerId)
order by usage.sortOrder asc, usage.createdAt asc
""")
List<MediaUsage> findByUsageScope(@Param("usageType") String usageType,
@Param("usageKey") String usageKey,
@Param("ownerId") UUID ownerId);
}

View File

@@ -0,0 +1,14 @@
package com.printcalculator.repository;
import com.printcalculator.entity.MediaVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public interface MediaVariantRepository extends JpaRepository<MediaVariant, UUID> {
List<MediaVariant> findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId);
List<MediaVariant> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
}

View File

@@ -9,4 +9,6 @@ import java.util.UUID;
public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> { public interface OrderItemRepository extends JpaRepository<OrderItem, UUID> {
List<OrderItem> findByOrder_Id(UUID orderId); List<OrderItem> findByOrder_Id(UUID orderId);
boolean existsByFilamentVariant_Id(Long filamentVariantId); boolean existsByFilamentVariant_Id(Long filamentVariantId);
boolean existsByShopProduct_Id(UUID shopProductId);
boolean existsByShopProductVariant_Id(UUID shopProductVariantId);
} }

View File

@@ -1,12 +1,30 @@
package com.printcalculator.repository; package com.printcalculator.repository;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> { public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId); List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
@EntityGraph(attributePaths = {"shopProductVariant"})
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
UUID quoteSessionId,
String lineItemType,
UUID shopProductVariantId
);
boolean existsByFilamentVariant_Id(Long filamentVariantId); boolean existsByFilamentVariant_Id(Long filamentVariantId);
boolean existsByShopProduct_Id(UUID shopProductId);
boolean existsByShopProductVariant_Id(UUID shopProductVariantId);
} }

View File

@@ -4,10 +4,13 @@ import com.printcalculator.entity.QuoteSession;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> { public interface QuoteSessionRepository extends JpaRepository<QuoteSession, UUID> {
List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff); List<QuoteSession> findByCreatedAtBefore(java.time.OffsetDateTime cutoff);
List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses); List<QuoteSession> findByStatusInOrderByCreatedAtDesc(List<String> statuses);
Optional<QuoteSession> findByIdAndSessionType(UUID id, String sessionType);
} }

View File

@@ -0,0 +1,24 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopCategoryRepository extends JpaRepository<ShopCategory, UUID> {
Optional<ShopCategory> findBySlug(String slug);
Optional<ShopCategory> findBySlugIgnoreCase(String slug);
Optional<ShopCategory> findBySlugAndIsActiveTrue(String slug);
boolean existsBySlugIgnoreCase(String slug);
boolean existsByParentCategory_Id(UUID parentCategoryId);
List<ShopCategory> findAllByOrderBySortOrderAscNameAsc();
List<ShopCategory> findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProductModelAsset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductModelAssetRepository extends JpaRepository<ShopProductModelAsset, UUID> {
Optional<ShopProductModelAsset> findByProduct_Id(UUID productId);
List<ShopProductModelAsset> findByProduct_IdIn(Collection<UUID> productIds);
void deleteByProduct_Id(UUID productId);
}

View File

@@ -0,0 +1,28 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProduct;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductRepository extends JpaRepository<ShopProduct, UUID> {
Optional<ShopProduct> findBySlug(String slug);
Optional<ShopProduct> findBySlugIgnoreCase(String slug);
Optional<ShopProduct> findBySlugAndIsActiveTrue(String slug);
boolean existsBySlugIgnoreCase(String slug);
List<ShopProduct> findAllByOrderBySortOrderAscNameAsc();
List<ShopProduct> findAllByOrderByIsFeaturedDescSortOrderAscNameAsc();
List<ShopProduct> findByCategory_IdOrderBySortOrderAscNameAsc(UUID categoryId);
List<ShopProduct> findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
boolean existsByCategory_Id(UUID categoryId);
}

View File

@@ -0,0 +1,25 @@
package com.printcalculator.repository;
import com.printcalculator.entity.ShopProductVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ShopProductVariantRepository extends JpaRepository<ShopProductVariant, UUID> {
List<ShopProductVariant> findByProduct_IdOrderBySortOrderAscColorNameAsc(UUID productId);
List<ShopProductVariant> findByProduct_IdInOrderBySortOrderAscColorNameAsc(Collection<UUID> productIds);
List<ShopProductVariant> findByProduct_IdAndIsActiveTrueOrderBySortOrderAscColorNameAsc(UUID productId);
List<ShopProductVariant> findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(Collection<UUID> productIds);
Optional<ShopProductVariant> findFirstByProduct_IdAndIsDefaultTrue(UUID productId);
boolean existsBySkuIgnoreCase(String sku);
boolean existsBySkuIgnoreCaseAndIdNot(String sku, UUID variantId);
}

View File

@@ -12,9 +12,11 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger;
@Service @Service
public class NozzleLayerHeightPolicyService { public class NozzleLayerHeightPolicyService {
private static final Logger logger = Logger.getLogger(NozzleLayerHeightPolicyService.class.getName());
private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP); private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP);
private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP); private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP);
@@ -27,7 +29,8 @@ public class NozzleLayerHeightPolicyService {
public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() { public Map<BigDecimal, List<BigDecimal>> getActiveRulesByNozzle() {
List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc(); List<NozzleLayerHeightOption> rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc();
if (rules.isEmpty()) { if (rules.isEmpty()) {
return fallbackRules(); logger.warning("No active nozzle->layer rules found in DB (table nozzle_layer_height_option is empty)");
return Map.of();
} }
Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>(); Map<BigDecimal, List<BigDecimal>> byNozzle = new LinkedHashMap<>();
@@ -120,24 +123,4 @@ public class NozzleLayerHeightPolicyService {
} }
} }
private Map<BigDecimal, List<BigDecimal>> fallbackRules() {
Map<BigDecimal, List<BigDecimal>> fallback = new LinkedHashMap<>();
fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12));
fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28));
fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36));
fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56));
return fallback;
}
private BigDecimal scaleNozzle(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
}
private List<BigDecimal> scaleLayers(double... values) {
List<BigDecimal> scaled = new ArrayList<>();
for (double value : values) {
scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP));
}
return scaled;
}
} }

View File

@@ -29,6 +29,7 @@ import java.util.*;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
private final OrderRepository orderRepo; private final OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -104,6 +105,7 @@ public class OrderService {
Order order = new Order(); Order order = new Order();
order.setSourceQuoteSession(session); order.setSourceQuoteSession(session);
order.setSourceType(resolveOrderSourceType(session));
order.setCustomer(customer); order.setCustomer(customer);
order.setCustomerEmail(request.getCustomer().getEmail()); order.setCustomerEmail(request.getCustomer().getEmail());
order.setCustomerPhone(request.getCustomer().getPhone()); order.setCustomerPhone(request.getCustomer().getPhone());
@@ -172,12 +174,27 @@ public class OrderService {
for (QuoteLineItem qItem : quoteItems) { for (QuoteLineItem qItem : quoteItems) {
OrderItem oItem = new OrderItem(); OrderItem oItem = new OrderItem();
oItem.setOrder(order); oItem.setOrder(order);
oItem.setItemType(qItem.getLineItemType() != null ? qItem.getLineItemType() : "PRINT_FILE");
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setDisplayName(
qItem.getDisplayName() != null && !qItem.getDisplayName().isBlank()
? qItem.getDisplayName()
: qItem.getOriginalFilename()
);
int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1; int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1;
oItem.setQuantity(quantity); oItem.setQuantity(quantity);
oItem.setColorCode(qItem.getColorCode()); oItem.setColorCode(qItem.getColorCode());
oItem.setFilamentVariant(qItem.getFilamentVariant()); oItem.setFilamentVariant(qItem.getFilamentVariant());
if (qItem.getFilamentVariant() != null oItem.setShopProduct(qItem.getShopProduct());
oItem.setShopProductVariant(qItem.getShopProductVariant());
oItem.setShopProductSlug(qItem.getShopProductSlug());
oItem.setShopProductName(qItem.getShopProductName());
oItem.setShopVariantLabel(qItem.getShopVariantLabel());
oItem.setShopVariantColorName(qItem.getShopVariantColorName());
oItem.setShopVariantColorHex(qItem.getShopVariantColorHex());
if (qItem.getMaterialCode() != null && !qItem.getMaterialCode().isBlank()) {
oItem.setMaterialCode(qItem.getMaterialCode());
} else if (qItem.getFilamentVariant() != null
&& qItem.getFilamentVariant().getFilamentMaterialType() != null && qItem.getFilamentVariant().getFilamentMaterialType() != null
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
@@ -219,18 +236,20 @@ public class OrderService {
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath);
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
if (sourcePath == null || !Files.exists(sourcePath)) { if (sourcePath == null || !Files.exists(sourcePath)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); if (requiresStoredSourceFile(qItem)) {
} throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
try { }
storageService.store(sourcePath, Paths.get(relativePath)); } else {
oItem.setFileSizeBytes(Files.size(sourcePath)); String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
} catch (IOException e) { oItem.setStoredRelativePath(relativePath);
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); try {
storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath));
} catch (IOException e) {
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
@@ -302,6 +321,12 @@ public class OrderService {
return "stl"; return "stl";
} }
private boolean requiresStoredSourceFile(QuoteLineItem qItem) {
return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase(
qItem.getLineItemType() != null ? qItem.getLineItemType() : ""
);
}
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) { if (storedPath == null || storedPath.isBlank()) {
return null; return null;
@@ -319,6 +344,13 @@ public class OrderService {
} }
} }
private String resolveOrderSourceType(QuoteSession session) {
if (session != null && "SHOP_CART".equalsIgnoreCase(session.getSessionType())) {
return "SHOP";
}
return "CALCULATOR";
}
private String getDisplayOrderNumber(Order order) { private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber(); String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) { if (orderNumber != null && !orderNumber.isBlank()) {

View File

@@ -161,10 +161,21 @@ public class AdminFilamentControllerService {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand()); String normalizedBrand = normalizeOptional(payload.getBrand());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
variant.setFilamentMaterialType(material); variant.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName); variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(normalizedColorName); variant.setColorName(normalizedColorName);
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
variant.setColorHex(normalizedColorHex); variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType); variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand); variant.setBrand(normalizedBrand);
@@ -226,6 +237,18 @@ public class AdminFilamentControllerService {
return normalized.isBlank() ? null : normalized; return normalized.isBlank() ? null : normalized;
} }
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) { if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
@@ -306,6 +329,10 @@ public class AdminFilamentControllerService {
dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName()); dto.setColorName(variant.getColorName());
dto.setColorLabelIt(variant.getColorLabelIt());
dto.setColorLabelEn(variant.getColorLabelEn());
dto.setColorLabelDe(variant.getColorLabelDe());
dto.setColorLabelFr(variant.getColorLabelFr());
dto.setColorHex(variant.getColorHex()); dto.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType()); dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand()); dto.setBrand(variant.getBrand());

View File

@@ -0,0 +1,870 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminCreateMediaUsageRequest;
import com.printcalculator.dto.AdminMediaAssetDto;
import com.printcalculator.dto.AdminMediaUsageDto;
import com.printcalculator.dto.AdminMediaVariantDto;
import com.printcalculator.dto.MediaTextTranslationDto;
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
import com.printcalculator.dto.AdminUpdateMediaUsageRequest;
import com.printcalculator.entity.MediaAsset;
import com.printcalculator.entity.MediaUsage;
import com.printcalculator.entity.MediaVariant;
import com.printcalculator.repository.MediaAssetRepository;
import com.printcalculator.repository.MediaUsageRepository;
import com.printcalculator.repository.MediaVariantRepository;
import com.printcalculator.service.media.MediaFfmpegService;
import com.printcalculator.service.media.MediaImageInspector;
import com.printcalculator.service.media.MediaStorageService;
import com.printcalculator.service.storage.ClamAVService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class AdminMediaControllerService {
private static final Logger logger = LoggerFactory.getLogger(AdminMediaControllerService.class);
private static final String STATUS_UPLOADED = "UPLOADED";
private static final String STATUS_PROCESSING = "PROCESSING";
private static final String STATUS_READY = "READY";
private static final String STATUS_FAILED = "FAILED";
private static final String STATUS_ARCHIVED = "ARCHIVED";
private static final String VISIBILITY_PUBLIC = "PUBLIC";
private static final String VISIBILITY_PRIVATE = "PRIVATE";
private static final String FORMAT_ORIGINAL = "ORIGINAL";
private static final String FORMAT_JPEG = "JPEG";
private static final String FORMAT_WEBP = "WEBP";
private static final String FORMAT_AVIF = "AVIF";
private static final Set<String> ALLOWED_STATUSES = Set.of(
STATUS_UPLOADED, STATUS_PROCESSING, STATUS_READY, STATUS_FAILED, STATUS_ARCHIVED
);
private static final Set<String> ALLOWED_VISIBILITIES = Set.of(VISIBILITY_PUBLIC, VISIBILITY_PRIVATE);
private static final Set<String> ALLOWED_UPLOAD_MIME_TYPES = Set.of(
"image/jpeg", "image/png", "image/webp"
);
private static final List<String> SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr");
private static final Map<String, String> GENERATED_FORMAT_MIME_TYPES = Map.of(
FORMAT_JPEG, "image/jpeg",
FORMAT_WEBP, "image/webp",
FORMAT_AVIF, "image/avif"
);
private static final Map<String, String> GENERATED_FORMAT_EXTENSIONS = Map.of(
FORMAT_JPEG, "jpg",
FORMAT_WEBP, "webp",
FORMAT_AVIF, "avif"
);
private static final List<PresetDefinition> PRESETS = List.of(
new PresetDefinition("thumb", 320),
new PresetDefinition("card", 640),
new PresetDefinition("hero", 1280)
);
private static final DateTimeFormatter STORAGE_FOLDER_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM");
private final MediaAssetRepository mediaAssetRepository;
private final MediaVariantRepository mediaVariantRepository;
private final MediaUsageRepository mediaUsageRepository;
private final MediaStorageService mediaStorageService;
private final MediaImageInspector mediaImageInspector;
private final MediaFfmpegService mediaFfmpegService;
private final ClamAVService clamAVService;
private final long maxUploadFileSizeBytes;
public AdminMediaControllerService(MediaAssetRepository mediaAssetRepository,
MediaVariantRepository mediaVariantRepository,
MediaUsageRepository mediaUsageRepository,
MediaStorageService mediaStorageService,
MediaImageInspector mediaImageInspector,
MediaFfmpegService mediaFfmpegService,
ClamAVService clamAVService,
@Value("${media.upload.max-file-size-bytes:26214400}") long maxUploadFileSizeBytes) {
this.mediaAssetRepository = mediaAssetRepository;
this.mediaVariantRepository = mediaVariantRepository;
this.mediaUsageRepository = mediaUsageRepository;
this.mediaStorageService = mediaStorageService;
this.mediaImageInspector = mediaImageInspector;
this.mediaFfmpegService = mediaFfmpegService;
this.clamAVService = clamAVService;
this.maxUploadFileSizeBytes = maxUploadFileSizeBytes;
}
@Transactional(noRollbackFor = ResponseStatusException.class)
public AdminMediaAssetDto uploadAsset(MultipartFile file,
String title,
String altText,
String visibility) {
validateUpload(file);
Path tempDirectory = null;
MediaAsset asset = null;
try {
String normalizedVisibility = normalizeVisibility(visibility, true);
tempDirectory = Files.createTempDirectory("media-asset-");
Path uploadFile = tempDirectory.resolve("upload.bin");
file.transferTo(uploadFile);
try (InputStream inputStream = Files.newInputStream(uploadFile)) {
clamAVService.scan(inputStream);
}
MediaImageInspector.ImageMetadata metadata = mediaImageInspector.inspect(uploadFile);
if (!ALLOWED_UPLOAD_MIME_TYPES.contains(metadata.mimeType())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unsupported image type. Allowed: jpg, jpeg, png, webp."
);
}
String storageFolder = buildStorageFolder();
String originalStorageKey = storageFolder + "/original." + metadata.fileExtension();
String normalizedFilename = sanitizeOriginalFilename(file.getOriginalFilename(), metadata.fileExtension());
String normalizedTitle = normalizeText(title);
String normalizedAltText = normalizeText(altText);
long originalFileSize = Files.size(uploadFile);
String sha256Hex = computeSha256(uploadFile);
mediaStorageService.storeOriginal(uploadFile, originalStorageKey);
OffsetDateTime now = OffsetDateTime.now();
asset = new MediaAsset();
asset.setOriginalFilename(normalizedFilename);
asset.setStorageKey(originalStorageKey);
asset.setMimeType(metadata.mimeType());
asset.setFileSizeBytes(originalFileSize);
asset.setSha256Hex(sha256Hex);
asset.setWidthPx(metadata.widthPx());
asset.setHeightPx(metadata.heightPx());
asset.setStatus(STATUS_UPLOADED);
asset.setVisibility(normalizedVisibility);
asset.setTitle(normalizedTitle);
asset.setAltText(normalizedAltText);
asset.setCreatedAt(now);
asset.setUpdatedAt(now);
asset = mediaAssetRepository.save(asset);
MediaVariant originalVariant = new MediaVariant();
originalVariant.setMediaAsset(asset);
originalVariant.setVariantName("original");
originalVariant.setFormat(FORMAT_ORIGINAL);
originalVariant.setStorageKey(originalStorageKey);
originalVariant.setMimeType(metadata.mimeType());
originalVariant.setWidthPx(metadata.widthPx());
originalVariant.setHeightPx(metadata.heightPx());
originalVariant.setFileSizeBytes(originalFileSize);
originalVariant.setIsGenerated(false);
originalVariant.setCreatedAt(now);
mediaVariantRepository.save(originalVariant);
asset.setStatus(STATUS_PROCESSING);
asset.setUpdatedAt(OffsetDateTime.now());
asset = mediaAssetRepository.save(asset);
List<MediaVariant> generatedVariants = generateDerivedVariants(asset, uploadFile, tempDirectory);
mediaVariantRepository.saveAll(generatedVariants);
asset.setStatus(STATUS_READY);
asset.setUpdatedAt(OffsetDateTime.now());
mediaAssetRepository.save(asset);
return getAsset(asset.getId());
} catch (ResponseStatusException e) {
markFailed(asset, e.getReason(), e);
throw e;
} catch (IOException e) {
markFailed(asset, "Media processing failed.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Media processing failed.");
} finally {
deleteRecursively(tempDirectory);
}
}
public List<AdminMediaAssetDto> listAssets() {
return toAssetDtos(mediaAssetRepository.findAllByOrderByCreatedAtDesc());
}
public AdminMediaAssetDto getAsset(UUID mediaAssetId) {
MediaAsset asset = getAssetOrThrow(mediaAssetId);
return toAssetDtos(List.of(asset)).getFirst();
}
@Transactional(noRollbackFor = ResponseStatusException.class)
public AdminMediaAssetDto updateAsset(UUID mediaAssetId, AdminUpdateMediaAssetRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required.");
}
MediaAsset asset = getAssetOrThrow(mediaAssetId);
String requestedVisibility = normalizeVisibility(payload.getVisibility(), false);
String requestedStatus = normalizeStatus(payload.getStatus(), false);
if (requestedVisibility != null && !requestedVisibility.equals(asset.getVisibility())) {
moveGeneratedVariants(asset, requestedVisibility);
asset.setVisibility(requestedVisibility);
}
if (requestedStatus != null) {
asset.setStatus(requestedStatus);
}
if (payload.getTitle() != null) {
asset.setTitle(normalizeText(payload.getTitle()));
}
if (payload.getAltText() != null) {
asset.setAltText(normalizeText(payload.getAltText()));
}
asset.setUpdatedAt(OffsetDateTime.now());
mediaAssetRepository.save(asset);
return getAsset(asset.getId());
}
@Transactional
public AdminMediaUsageDto createUsage(AdminCreateMediaUsageRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required.");
}
MediaAsset asset = getAssetOrThrow(payload.getMediaAssetId());
String usageType = requireUsageType(payload.getUsageType());
String usageKey = requireUsageKey(payload.getUsageKey());
boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary());
Map<String, MediaTextTranslationDto> translations = requireTranslations(payload.getTranslations());
if (isPrimary) {
unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null);
}
MediaUsage usage = new MediaUsage();
usage.setUsageType(usageType);
usage.setUsageKey(usageKey);
usage.setOwnerId(payload.getOwnerId());
usage.setMediaAsset(asset);
usage.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0);
usage.setIsPrimary(isPrimary);
usage.setIsActive(payload.getIsActive() == null || payload.getIsActive());
usage.setCreatedAt(OffsetDateTime.now());
applyTranslations(usage, translations);
MediaUsage saved = mediaUsageRepository.save(usage);
return toUsageDto(saved);
}
@Transactional
public AdminMediaUsageDto updateUsage(UUID mediaUsageId, AdminUpdateMediaUsageRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required.");
}
MediaUsage usage = getUsageOrThrow(mediaUsageId);
if (payload.getUsageType() != null) {
usage.setUsageType(requireUsageType(payload.getUsageType()));
}
if (payload.getUsageKey() != null) {
usage.setUsageKey(requireUsageKey(payload.getUsageKey()));
}
if (payload.getOwnerId() != null) {
usage.setOwnerId(payload.getOwnerId());
}
if (payload.getMediaAssetId() != null) {
usage.setMediaAsset(getAssetOrThrow(payload.getMediaAssetId()));
}
if (payload.getSortOrder() != null) {
usage.setSortOrder(payload.getSortOrder());
}
if (payload.getIsActive() != null) {
usage.setIsActive(payload.getIsActive());
}
if (payload.getIsPrimary() != null) {
usage.setIsPrimary(payload.getIsPrimary());
}
if (payload.getTranslations() != null) {
applyTranslations(usage, requireTranslations(payload.getTranslations()));
}
if (Boolean.TRUE.equals(usage.getIsPrimary())) {
unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId());
}
MediaUsage saved = mediaUsageRepository.save(usage);
return toUsageDto(saved);
}
@Transactional
public void deleteUsage(UUID mediaUsageId) {
mediaUsageRepository.delete(getUsageOrThrow(mediaUsageId));
}
public List<AdminMediaUsageDto> getUsages(String usageType, String usageKey, UUID ownerId) {
String normalizedUsageType = requireUsageType(usageType);
String normalizedUsageKey = requireUsageKey(usageKey);
return mediaUsageRepository.findByUsageScope(normalizedUsageType, normalizedUsageKey, ownerId)
.stream()
.sorted(Comparator
.comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)))
.map(this::toUsageDto)
.toList();
}
private List<MediaVariant> generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException {
Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated"));
String storageFolder = extractStorageFolder(asset.getStorageKey());
List<PendingGeneratedVariant> pendingVariants = new ArrayList<>();
Set<String> skippedFormats = new LinkedHashSet<>();
for (PresetDefinition preset : PRESETS) {
VariantDimensions dimensions = computeVariantDimensions(
asset.getWidthPx(),
asset.getHeightPx(),
preset.maxDimension()
);
for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) {
if (!mediaFfmpegService.canEncode(format)) {
skippedFormats.add(format);
continue;
}
String extension = GENERATED_FORMAT_EXTENSIONS.get(format);
Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension);
try {
mediaFfmpegService.generateVariant(
sourceFile,
outputFile,
dimensions.widthPx(),
dimensions.heightPx(),
format
);
} catch (IOException e) {
if (FORMAT_AVIF.equals(format)) {
skippedFormats.add(format);
logger.warn(
"Skipping AVIF variant generation for asset {} preset '{}' because FFmpeg AVIF generation failed: {}",
asset.getId(),
preset.name(),
e.getMessage()
);
continue;
}
throw e;
}
MediaVariant variant = new MediaVariant();
variant.setMediaAsset(asset);
variant.setVariantName(preset.name());
variant.setFormat(format);
variant.setStorageKey(storageFolder + "/" + preset.name() + "." + extension);
variant.setMimeType(GENERATED_FORMAT_MIME_TYPES.get(format));
variant.setWidthPx(dimensions.widthPx());
variant.setHeightPx(dimensions.heightPx());
variant.setFileSizeBytes(Files.size(outputFile));
variant.setIsGenerated(true);
variant.setCreatedAt(OffsetDateTime.now());
pendingVariants.add(new PendingGeneratedVariant(variant, outputFile));
}
}
if (!skippedFormats.isEmpty()) {
logger.warn(
"Skipping media formats for asset {} because FFmpeg support is unavailable: {}",
asset.getId(),
String.join(", ", skippedFormats)
);
}
List<String> storedKeys = new ArrayList<>();
try {
for (PendingGeneratedVariant pendingVariant : pendingVariants) {
storeGeneratedVariant(asset.getVisibility(), pendingVariant);
storedKeys.add(pendingVariant.variant().getStorageKey());
}
} catch (IOException e) {
cleanupStoredGeneratedVariants(asset.getVisibility(), storedKeys);
throw e;
}
return pendingVariants.stream()
.map(PendingGeneratedVariant::variant)
.toList();
}
private void storeGeneratedVariant(String visibility, PendingGeneratedVariant pendingVariant) throws IOException {
if (VISIBILITY_PUBLIC.equals(visibility)) {
mediaStorageService.storePublic(pendingVariant.file(), pendingVariant.variant().getStorageKey());
return;
}
mediaStorageService.storePrivate(pendingVariant.file(), pendingVariant.variant().getStorageKey());
}
private void cleanupStoredGeneratedVariants(String visibility, Collection<String> storageKeys) {
for (String storageKey : storageKeys) {
try {
mediaStorageService.deleteGenerated(visibility, storageKey);
} catch (IOException cleanupException) {
logger.warn("Failed to clean up media variant {}", storageKey, cleanupException);
}
}
}
private void moveGeneratedVariants(MediaAsset asset, String requestedVisibility) {
List<MediaVariant> variants = mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(asset.getId());
List<String> movedStorageKeys = new ArrayList<>();
try {
for (MediaVariant variant : variants) {
if (FORMAT_ORIGINAL.equals(variant.getFormat())) {
continue;
}
mediaStorageService.moveGenerated(variant.getStorageKey(), asset.getVisibility(), requestedVisibility);
movedStorageKeys.add(variant.getStorageKey());
}
} catch (IOException e) {
reverseMovedVariants(asset.getVisibility(), requestedVisibility, movedStorageKeys);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to move media files.");
}
}
private void reverseMovedVariants(String originalVisibility, String requestedVisibility, List<String> movedStorageKeys) {
List<String> reversedOrder = new ArrayList<>(movedStorageKeys);
java.util.Collections.reverse(reversedOrder);
for (String storageKey : reversedOrder) {
try {
mediaStorageService.moveGenerated(storageKey, requestedVisibility, originalVisibility);
} catch (IOException reverseException) {
logger.error("Failed to restore media variant {}", storageKey, reverseException);
}
}
}
private void unsetPrimaryForScope(String usageType, String usageKey, UUID ownerId, UUID excludeUsageId) {
List<MediaUsage> existingUsages = mediaUsageRepository.findByUsageScope(usageType, usageKey, ownerId);
List<MediaUsage> usagesToUpdate = existingUsages.stream()
.filter(existing -> excludeUsageId == null || !existing.getId().equals(excludeUsageId))
.filter(existing -> Boolean.TRUE.equals(existing.getIsPrimary()))
.peek(existing -> existing.setIsPrimary(false))
.toList();
if (!usagesToUpdate.isEmpty()) {
mediaUsageRepository.saveAll(usagesToUpdate);
}
}
private List<AdminMediaAssetDto> toAssetDtos(List<MediaAsset> assets) {
if (assets == null || assets.isEmpty()) {
return List.of();
}
List<UUID> assetIds = assets.stream()
.map(MediaAsset::getId)
.filter(Objects::nonNull)
.toList();
Map<UUID, List<MediaVariant>> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds)
.stream()
.sorted(this::compareVariants)
.collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList()));
Map<UUID, List<MediaUsage>> usagesByAssetId = mediaUsageRepository.findByMediaAsset_IdIn(assetIds)
.stream()
.sorted(Comparator
.comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)))
.collect(Collectors.groupingBy(usage -> usage.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList()));
return assets.stream()
.map(asset -> toAssetDto(
asset,
variantsByAssetId.getOrDefault(asset.getId(), List.of()),
usagesByAssetId.getOrDefault(asset.getId(), List.of())
))
.toList();
}
private AdminMediaAssetDto toAssetDto(MediaAsset asset, List<MediaVariant> variants, List<MediaUsage> usages) {
AdminMediaAssetDto dto = new AdminMediaAssetDto();
dto.setId(asset.getId());
dto.setOriginalFilename(asset.getOriginalFilename());
dto.setStorageKey(asset.getStorageKey());
dto.setMimeType(asset.getMimeType());
dto.setFileSizeBytes(asset.getFileSizeBytes());
dto.setSha256Hex(asset.getSha256Hex());
dto.setWidthPx(asset.getWidthPx());
dto.setHeightPx(asset.getHeightPx());
dto.setStatus(asset.getStatus());
dto.setVisibility(asset.getVisibility());
dto.setTitle(asset.getTitle());
dto.setAltText(asset.getAltText());
dto.setCreatedAt(asset.getCreatedAt());
dto.setUpdatedAt(asset.getUpdatedAt());
dto.setVariants(variants.stream().map(variant -> toVariantDto(asset, variant)).toList());
dto.setUsages(usages.stream().map(this::toUsageDto).toList());
return dto;
}
private AdminMediaVariantDto toVariantDto(MediaAsset asset, MediaVariant variant) {
AdminMediaVariantDto dto = new AdminMediaVariantDto();
dto.setId(variant.getId());
dto.setVariantName(variant.getVariantName());
dto.setFormat(variant.getFormat());
dto.setStorageKey(variant.getStorageKey());
dto.setMimeType(variant.getMimeType());
dto.setWidthPx(variant.getWidthPx());
dto.setHeightPx(variant.getHeightPx());
dto.setFileSizeBytes(variant.getFileSizeBytes());
dto.setIsGenerated(variant.getIsGenerated());
dto.setCreatedAt(variant.getCreatedAt());
if (VISIBILITY_PUBLIC.equals(asset.getVisibility()) && !FORMAT_ORIGINAL.equals(variant.getFormat())) {
dto.setPublicUrl(mediaStorageService.buildPublicUrl(variant.getStorageKey()));
}
return dto;
}
private AdminMediaUsageDto toUsageDto(MediaUsage usage) {
AdminMediaUsageDto dto = new AdminMediaUsageDto();
dto.setId(usage.getId());
dto.setUsageType(usage.getUsageType());
dto.setUsageKey(usage.getUsageKey());
dto.setOwnerId(usage.getOwnerId());
dto.setMediaAssetId(usage.getMediaAsset().getId());
dto.setSortOrder(usage.getSortOrder());
dto.setIsPrimary(usage.getIsPrimary());
dto.setIsActive(usage.getIsActive());
dto.setTranslations(extractTranslations(usage));
dto.setCreatedAt(usage.getCreatedAt());
return dto;
}
private int compareVariants(MediaVariant left, MediaVariant right) {
return Comparator
.comparingInt((MediaVariant variant) -> variantNameOrder(variant.getVariantName()))
.thenComparingInt(variant -> formatOrder(variant.getFormat()))
.thenComparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))
.compare(left, right);
}
private int variantNameOrder(String variantName) {
if ("original".equalsIgnoreCase(variantName)) {
return 0;
}
if ("thumb".equalsIgnoreCase(variantName)) {
return 10;
}
if ("card".equalsIgnoreCase(variantName)) {
return 20;
}
if ("hero".equalsIgnoreCase(variantName)) {
return 30;
}
return 100;
}
private int formatOrder(String format) {
return switch (format) {
case FORMAT_ORIGINAL -> 0;
case FORMAT_JPEG -> 10;
case FORMAT_WEBP -> 20;
case FORMAT_AVIF -> 30;
default -> 100;
};
}
private MediaAsset getAssetOrThrow(UUID mediaAssetId) {
if (mediaAssetId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Media asset id is required.");
}
return mediaAssetRepository.findById(mediaAssetId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media asset not found."));
}
private MediaUsage getUsageOrThrow(UUID mediaUsageId) {
return mediaUsageRepository.findById(mediaUsageId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media usage not found."));
}
private void validateUpload(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file is required.");
}
if (file.getSize() < 0 || file.getSize() > maxUploadFileSizeBytes) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file exceeds the maximum allowed size.");
}
}
private String requireUsageType(String usageType) {
if (usageType == null || usageType.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required.");
}
return usageType.trim().toUpperCase(Locale.ROOT);
}
private String requireUsageKey(String usageKey) {
if (usageKey == null || usageKey.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required.");
}
return usageKey.trim();
}
private String normalizeVisibility(String visibility, boolean defaultPublic) {
if (visibility == null) {
return defaultPublic ? VISIBILITY_PUBLIC : null;
}
String normalized = visibility.trim().toUpperCase(Locale.ROOT);
if (normalized.isBlank()) {
return defaultPublic ? VISIBILITY_PUBLIC : null;
}
if (!ALLOWED_VISIBILITIES.contains(normalized)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid visibility. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_VISIBILITIES))
);
}
return normalized;
}
private String normalizeStatus(String status, boolean required) {
if (status == null) {
if (required) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required.");
}
return null;
}
String normalized = status.trim().toUpperCase(Locale.ROOT);
if (normalized.isBlank()) {
if (required) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required.");
}
return null;
}
if (!ALLOWED_STATUSES.contains(normalized)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid status. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_STATUSES))
);
}
return normalized;
}
private Map<String, MediaTextTranslationDto> requireTranslations(Map<String, MediaTextTranslationDto> translations) {
if (translations == null || translations.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "translations are required.");
}
Map<String, MediaTextTranslationDto> normalized = new LinkedHashMap<>();
for (Map.Entry<String, MediaTextTranslationDto> entry : translations.entrySet()) {
String language = normalizeTranslationLanguage(entry.getKey());
if (normalized.containsKey(language)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate translation language: " + language + ".");
}
normalized.put(language, entry.getValue());
}
if (!normalized.keySet().equals(new LinkedHashSet<>(SUPPORTED_MEDIA_LANGUAGES))) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"translations must include exactly: " + String.join(", ", SUPPORTED_MEDIA_LANGUAGES) + "."
);
}
LinkedHashMap<String, MediaTextTranslationDto> result = new LinkedHashMap<>();
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
MediaTextTranslationDto translation = normalized.get(language);
if (translation == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing translation for language " + language + ".");
}
String title = normalizeRequiredTranslationValue(translation.getTitle(), language, "title");
String altText = normalizeRequiredTranslationValue(translation.getAltText(), language, "altText");
MediaTextTranslationDto dto = new MediaTextTranslationDto();
dto.setTitle(title);
dto.setAltText(altText);
result.put(language, dto);
}
return result;
}
private String normalizeTranslationLanguage(String language) {
if (language == null || language.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation language is required.");
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
if (!SUPPORTED_MEDIA_LANGUAGES.contains(normalized)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unsupported translation language: " + normalized + "."
);
}
return normalized;
}
private String normalizeRequiredTranslationValue(String value, String language, String fieldName) {
String normalized = normalizeText(value);
if (normalized == null || normalized.isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Translation " + fieldName + " is required for language " + language + "."
);
}
return normalized;
}
private void applyTranslations(MediaUsage usage, Map<String, MediaTextTranslationDto> translations) {
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
MediaTextTranslationDto translation = translations.get(language);
usage.setTitleForLanguage(language, translation.getTitle());
usage.setAltTextForLanguage(language, translation.getAltText());
}
}
private Map<String, MediaTextTranslationDto> extractTranslations(MediaUsage usage) {
LinkedHashMap<String, MediaTextTranslationDto> translations = new LinkedHashMap<>();
String fallbackTitle = usage.getMediaAsset() != null ? usage.getMediaAsset().getTitle() : null;
String fallbackAltText = usage.getMediaAsset() != null ? usage.getMediaAsset().getAltText() : null;
for (String language : SUPPORTED_MEDIA_LANGUAGES) {
MediaTextTranslationDto dto = new MediaTextTranslationDto();
dto.setTitle(firstNonBlank(usage.getTitleForLanguage(language), fallbackTitle));
dto.setAltText(firstNonBlank(usage.getAltTextForLanguage(language), fallbackAltText));
translations.put(language, dto);
}
return translations;
}
private String firstNonBlank(String preferred, String fallback) {
return StringUtils.hasText(preferred) ? preferred : normalizeText(fallback);
}
private String normalizeText(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String sanitizeOriginalFilename(String originalFilename, String extension) {
String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename);
int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned;
basename = basename.replace("\r", "_").replace("\n", "_");
if (basename.isBlank()) {
return "upload." + extension;
}
return basename;
}
private String buildStorageFolder() {
return STORAGE_FOLDER_FORMATTER.format(LocalDate.now()) + "/" + UUID.randomUUID();
}
private String extractStorageFolder(String originalStorageKey) {
Path path = Paths.get(originalStorageKey).normalize();
Path parent = path.getParent();
if (parent == null) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid media storage key.");
}
return parent.toString().replace('\\', '/');
}
private VariantDimensions computeVariantDimensions(Integer widthPx, Integer heightPx, int maxDimension) {
if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid image dimensions.");
}
double scale = Math.min(1.0d, (double) maxDimension / Math.max(widthPx, heightPx));
int targetWidth = Math.max(1, (int) Math.round(widthPx * scale));
int targetHeight = Math.max(1, (int) Math.round(heightPx * scale));
return new VariantDimensions(targetWidth, targetHeight);
}
private String computeSha256(Path file) throws IOException {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available.", e);
}
try (InputStream inputStream = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) >= 0) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private void markFailed(MediaAsset asset, String message, Exception exception) {
if (asset == null || asset.getId() == null) {
logger.warn("Media upload failed before asset persistence: {}", message, exception);
return;
}
asset.setStatus(STATUS_FAILED);
asset.setUpdatedAt(OffsetDateTime.now());
mediaAssetRepository.save(asset);
logger.warn("Media asset {} marked as FAILED: {}", asset.getId(), message, exception);
}
private void deleteRecursively(Path directory) {
if (directory == null || !Files.exists(directory)) {
return;
}
try (var walk = Files.walk(directory)) {
walk.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
} catch (IOException e) {
logger.warn("Failed to clean temporary media directory {}", directory, e);
} catch (UncheckedIOException e) {
logger.warn("Failed to clean temporary media directory {}", directory, e);
}
}
private record PresetDefinition(String name, int maxDimension) {
}
private record VariantDimensions(int widthPx, int heightPx) {
}
private record PendingGeneratedVariant(MediaVariant variant, Path file) {
}
}

View File

@@ -301,6 +301,7 @@ public class AdminOperationsControllerService {
} else { } else {
session = new QuoteSession(); session = new QuoteSession();
session.setStatus("CAD_ACTIVE"); session.setStatus("CAD_ACTIVE");
session.setSessionType("PRINT_QUOTE");
session.setPricingVersion("v1"); session.setPricingVersion("v1");
session.setMaterialCode("PLA"); session.setMaterialCode("PLA");
session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); session.setNozzleDiameterMm(BigDecimal.valueOf(0.4));
@@ -398,6 +399,7 @@ public class AdminOperationsControllerService {
AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); AdminQuoteSessionDto dto = new AdminQuoteSessionDto();
dto.setId(session.getId()); dto.setId(session.getId());
dto.setStatus(session.getStatus()); dto.setStatus(session.getStatus());
dto.setSessionType(session.getSessionType() != null ? session.getSessionType() : "PRINT_QUOTE");
dto.setMaterialCode(session.getMaterialCode()); dto.setMaterialCode(session.getMaterialCode());
dto.setCreatedAt(session.getCreatedAt()); dto.setCreatedAt(session.getCreatedAt());
dto.setExpiresAt(session.getExpiresAt()); dto.setExpiresAt(session.getExpiresAt());

View File

@@ -0,0 +1,467 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminShopCategoryDto;
import com.printcalculator.dto.AdminShopCategoryRefDto;
import com.printcalculator.dto.AdminUpsertShopCategoryRequest;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.text.Normalizer;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service
@Transactional(readOnly = true)
public class AdminShopCategoryControllerService {
private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY";
private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+");
private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)");
private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository;
public AdminShopCategoryControllerService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository) {
this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository;
}
public List<AdminShopCategoryDto> getCategories() {
CategoryContext context = buildContext();
List<AdminShopCategoryDto> result = new ArrayList<>();
appendFlatCategories(null, 0, context, result);
return result;
}
public List<AdminShopCategoryDto> getCategoryTree() {
return buildCategoryTree(null, 0, buildContext());
}
public AdminShopCategoryDto getCategory(UUID categoryId) {
CategoryContext context = buildContext();
ShopCategory category = context.categoriesById().get(categoryId);
if (category == null) {
throw new ResponseStatusException(NOT_FOUND, "Shop category not found");
}
return toDto(category, resolveDepth(category), context, true);
}
@Transactional
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
ensurePayload(payload);
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, null);
ShopCategory category = new ShopCategory();
category.setCreatedAt(OffsetDateTime.now());
applyPayload(category, payload, localizedContent, normalizedSlug, null);
ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId());
}
@Transactional
public AdminShopCategoryDto updateCategory(UUID categoryId, AdminUpsertShopCategoryRequest payload) {
ensurePayload(payload);
ShopCategory category = shopCategoryRepository.findById(categoryId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, category.getId());
applyPayload(category, payload, localizedContent, normalizedSlug, category.getId());
ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId());
}
@Transactional
public void deleteCategory(UUID categoryId) {
ShopCategory category = shopCategoryRepository.findById(categoryId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
if (shopCategoryRepository.existsByParentCategory_Id(categoryId)) {
throw new ResponseStatusException(CONFLICT, "Category has child categories and cannot be deleted");
}
if (shopProductRepository.existsByCategory_Id(categoryId)) {
throw new ResponseStatusException(CONFLICT, "Category has products and cannot be deleted");
}
shopCategoryRepository.delete(category);
}
private void applyPayload(ShopCategory category,
AdminUpsertShopCategoryRequest payload,
LocalizedCategoryContent localizedContent,
String normalizedSlug,
UUID currentCategoryId) {
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
category.setParentCategory(parentCategory);
category.setSlug(normalizedSlug);
category.setName(localizedContent.defaultName());
category.setNameIt(localizedContent.names().get("it"));
category.setNameEn(localizedContent.names().get("en"));
category.setNameDe(localizedContent.names().get("de"));
category.setNameFr(localizedContent.names().get("fr"));
category.setDescription(localizedContent.defaultDescription());
category.setDescriptionIt(localizedContent.descriptions().get("it"));
category.setDescriptionEn(localizedContent.descriptions().get("en"));
category.setDescriptionDe(localizedContent.descriptions().get("de"));
category.setDescriptionFr(localizedContent.descriptions().get("fr"));
category.setSeoTitle(localizedContent.defaultSeoTitle());
category.setSeoTitleIt(localizedContent.seoTitles().get("it"));
category.setSeoTitleEn(localizedContent.seoTitles().get("en"));
category.setSeoTitleDe(localizedContent.seoTitles().get("de"));
category.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
category.setSeoDescription(localizedContent.defaultSeoDescription());
category.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
category.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
category.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
category.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
category.setOgTitle(normalizeOptional(payload.getOgTitle()));
category.setOgDescription(normalizeOptional(payload.getOgDescription()));
category.setIndexable(payload.getIndexable() == null || payload.getIndexable());
category.setIsActive(payload.getIsActive() == null || payload.getIsActive());
category.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0);
category.setUpdatedAt(OffsetDateTime.now());
}
private ShopCategory resolveParentCategory(UUID parentCategoryId, UUID currentCategoryId) {
if (parentCategoryId == null) {
return null;
}
if (currentCategoryId != null && currentCategoryId.equals(parentCategoryId)) {
throw new ResponseStatusException(BAD_REQUEST, "Category cannot be its own parent");
}
ShopCategory parentCategory = shopCategoryRepository.findById(parentCategoryId)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Parent category not found"));
if (currentCategoryId != null) {
ShopCategory ancestor = parentCategory;
while (ancestor != null) {
if (currentCategoryId.equals(ancestor.getId())) {
throw new ResponseStatusException(BAD_REQUEST, "Category hierarchy would create a cycle");
}
ancestor = ancestor.getParentCategory();
}
}
return parentCategory;
}
private void ensurePayload(AdminUpsertShopCategoryRequest payload) {
if (payload == null) {
throw new ResponseStatusException(BAD_REQUEST, "Payload is required");
}
}
private String normalizeAndValidateSlug(String slug, String fallbackName) {
String source = normalizeOptional(slug);
if (source == null) {
source = fallbackName;
}
String normalized = Normalizer.normalize(source, Normalizer.Form.NFD);
normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll("");
normalized = normalized.toLowerCase(Locale.ROOT);
normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-");
normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll("");
if (normalized.isBlank()) {
throw new ResponseStatusException(BAD_REQUEST, "Slug is invalid");
}
return normalized;
}
private void ensureSlugAvailable(String slug, UUID currentCategoryId) {
shopCategoryRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> {
if (currentCategoryId == null || !existing.getId().equals(currentCategoryId)) {
throw new ResponseStatusException(BAD_REQUEST, "Category slug already exists");
}
});
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeOptional(value);
if (normalized == null) {
throw new ResponseStatusException(BAD_REQUEST, message);
}
return normalized;
}
private LocalizedCategoryContent normalizeLocalizedCategoryContent(AdminUpsertShopCategoryRequest payload) {
String legacyName = normalizeOptional(payload.getName());
String fallbackName = firstNonBlank(
legacyName,
normalizeOptional(payload.getNameIt()),
normalizeOptional(payload.getNameEn()),
normalizeOptional(payload.getNameDe()),
normalizeOptional(payload.getNameFr())
);
if (fallbackName == null) {
throw new ResponseStatusException(BAD_REQUEST, "Category name is required");
}
Map<String, String> names = new LinkedHashMap<>();
names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian category name is required"));
names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English category name is required"));
names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German category name is required"));
names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French category name is required"));
String fallbackDescription = firstNonBlank(
normalizeOptional(payload.getDescription()),
normalizeOptional(payload.getDescriptionIt()),
normalizeOptional(payload.getDescriptionEn()),
normalizeOptional(payload.getDescriptionDe()),
normalizeOptional(payload.getDescriptionFr())
);
Map<String, String> descriptions = new LinkedHashMap<>();
descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription));
descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription));
descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription));
descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription));
String fallbackSeoTitle = firstNonBlank(
normalizeOptional(payload.getSeoTitle()),
normalizeOptional(payload.getSeoTitleIt()),
normalizeOptional(payload.getSeoTitleEn()),
normalizeOptional(payload.getSeoTitleDe()),
normalizeOptional(payload.getSeoTitleFr())
);
Map<String, String> seoTitles = new LinkedHashMap<>();
seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle));
seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle));
seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle));
seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle));
String fallbackSeoDescription = firstNonBlank(
normalizeOptional(payload.getSeoDescription()),
normalizeOptional(payload.getSeoDescriptionIt()),
normalizeOptional(payload.getSeoDescriptionEn()),
normalizeOptional(payload.getSeoDescriptionDe()),
normalizeOptional(payload.getSeoDescriptionFr())
);
Map<String, String> seoDescriptions = new LinkedHashMap<>();
seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian"));
seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English"));
seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German"));
seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French"));
return new LocalizedCategoryContent(
names.get("it"),
firstNonBlank(descriptions.get("it"), fallbackDescription),
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
names,
descriptions,
seoTitles,
seoDescriptions
);
}
private String validateSeoDescriptionLength(String value, String languageLabel) {
if (value != null && value.length() > 160) {
throw new ResponseStatusException(BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
}
return value;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private CategoryContext buildContext() {
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
List<ShopProduct> products = shopProductRepository.findAll();
Map<UUID, ShopCategory> categoriesById = categories.stream()
.collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new));
Map<UUID, List<ShopCategory>> childrenByParentId = new LinkedHashMap<>();
for (ShopCategory category : categories) {
UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null;
childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category);
}
Comparator<ShopCategory> comparator = Comparator
.comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER));
childrenByParentId.values().forEach(children -> children.sort(comparator));
Map<UUID, Integer> directProductCounts = new LinkedHashMap<>();
for (ShopProduct product : products) {
if (product.getCategory() == null || product.getCategory().getId() == null) {
continue;
}
directProductCounts.merge(product.getCategory().getId(), 1, Integer::sum);
}
Map<UUID, Integer> descendantProductCounts = new LinkedHashMap<>();
for (ShopCategory category : categories) {
resolveDescendantProductCount(category.getId(), childrenByParentId, directProductCounts, descendantProductCounts);
}
return new CategoryContext(categoriesById, childrenByParentId, directProductCounts, descendantProductCounts);
}
private int resolveDescendantProductCount(UUID categoryId,
Map<UUID, List<ShopCategory>> childrenByParentId,
Map<UUID, Integer> directProductCounts,
Map<UUID, Integer> descendantProductCounts) {
Integer cached = descendantProductCounts.get(categoryId);
if (cached != null) {
return cached;
}
int total = directProductCounts.getOrDefault(categoryId, 0);
for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) {
total += resolveDescendantProductCount(child.getId(), childrenByParentId, directProductCounts, descendantProductCounts);
}
descendantProductCounts.put(categoryId, total);
return total;
}
private void appendFlatCategories(UUID parentId,
int depth,
CategoryContext context,
List<AdminShopCategoryDto> result) {
for (ShopCategory category : context.childrenByParentId().getOrDefault(parentId, List.of())) {
result.add(toDto(category, depth, context, false));
appendFlatCategories(category.getId(), depth + 1, context, result);
}
}
private List<AdminShopCategoryDto> buildCategoryTree(UUID parentId, int depth, CategoryContext context) {
return context.childrenByParentId().getOrDefault(parentId, List.of()).stream()
.map(category -> toDto(category, depth, context, true))
.toList();
}
private AdminShopCategoryDto toDto(ShopCategory category,
int depth,
CategoryContext context,
boolean includeChildren) {
AdminShopCategoryDto dto = new AdminShopCategoryDto();
dto.setId(category.getId());
dto.setParentCategoryId(category.getParentCategory() != null ? category.getParentCategory().getId() : null);
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
dto.setSlug(category.getSlug());
dto.setName(category.getName());
dto.setNameIt(category.getNameIt());
dto.setNameEn(category.getNameEn());
dto.setNameDe(category.getNameDe());
dto.setNameFr(category.getNameFr());
dto.setDescription(category.getDescription());
dto.setDescriptionIt(category.getDescriptionIt());
dto.setDescriptionEn(category.getDescriptionEn());
dto.setDescriptionDe(category.getDescriptionDe());
dto.setDescriptionFr(category.getDescriptionFr());
dto.setSeoTitle(category.getSeoTitle());
dto.setSeoTitleIt(category.getSeoTitleIt());
dto.setSeoTitleEn(category.getSeoTitleEn());
dto.setSeoTitleDe(category.getSeoTitleDe());
dto.setSeoTitleFr(category.getSeoTitleFr());
dto.setSeoDescription(category.getSeoDescription());
dto.setSeoDescriptionIt(category.getSeoDescriptionIt());
dto.setSeoDescriptionEn(category.getSeoDescriptionEn());
dto.setSeoDescriptionDe(category.getSeoDescriptionDe());
dto.setSeoDescriptionFr(category.getSeoDescriptionFr());
dto.setOgTitle(category.getOgTitle());
dto.setOgDescription(category.getOgDescription());
dto.setIndexable(category.getIndexable());
dto.setIsActive(category.getIsActive());
dto.setSortOrder(category.getSortOrder());
dto.setDepth(depth);
dto.setChildCount(context.childrenByParentId().getOrDefault(category.getId(), List.of()).size());
dto.setDirectProductCount(context.directProductCounts().getOrDefault(category.getId(), 0));
dto.setDescendantProductCount(context.descendantProductCounts().getOrDefault(category.getId(), 0));
dto.setMediaUsageType(SHOP_CATEGORY_MEDIA_USAGE_TYPE);
dto.setMediaUsageKey(category.getId().toString());
dto.setBreadcrumbs(buildBreadcrumbs(category));
dto.setChildren(includeChildren ? buildCategoryTree(category.getId(), depth + 1, context) : List.of());
dto.setCreatedAt(category.getCreatedAt());
dto.setUpdatedAt(category.getUpdatedAt());
return dto;
}
private List<AdminShopCategoryRefDto> buildBreadcrumbs(ShopCategory category) {
List<AdminShopCategoryRefDto> breadcrumbs = new ArrayList<>();
ShopCategory current = category;
while (current != null) {
AdminShopCategoryRefDto ref = new AdminShopCategoryRefDto();
ref.setId(current.getId());
ref.setSlug(current.getSlug());
ref.setName(current.getName());
breadcrumbs.add(ref);
current = current.getParentCategory();
}
java.util.Collections.reverse(breadcrumbs);
return breadcrumbs;
}
private int resolveDepth(ShopCategory category) {
int depth = 0;
ShopCategory current = category != null ? category.getParentCategory() : null;
while (current != null) {
depth++;
current = current.getParentCategory();
}
return depth;
}
private record CategoryContext(
Map<UUID, ShopCategory> categoriesById,
Map<UUID, List<ShopCategory>> childrenByParentId,
Map<UUID, Integer> directProductCounts,
Map<UUID, Integer> descendantProductCounts
) {
}
private record LocalizedCategoryContent(
String defaultName,
String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) {
}
}

View File

@@ -0,0 +1,907 @@
package com.printcalculator.service.admin;
import com.printcalculator.dto.AdminMediaUsageDto;
import com.printcalculator.dto.AdminShopProductDto;
import com.printcalculator.dto.AdminShopProductVariantDto;
import com.printcalculator.dto.AdminUpsertShopProductRequest;
import com.printcalculator.dto.AdminUpsertShopProductVariantRequest;
import com.printcalculator.dto.PublicMediaUsageDto;
import com.printcalculator.dto.ShopProductModelDto;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.model.ModelDimensions;
import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.media.PublicMediaQueryService;
import com.printcalculator.service.shop.ShopStorageService;
import com.printcalculator.service.storage.ClamAVService;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class AdminShopProductControllerService {
private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT";
private static final Set<String> SUPPORTED_MODEL_EXTENSIONS = Set.of("stl", "3mf");
private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$");
private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+");
private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)");
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "http", "https", "mailto", "tel");
private final ShopProductRepository shopProductRepository;
private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductVariantRepository shopProductVariantRepository;
private final ShopProductModelAssetRepository shopProductModelAssetRepository;
private final QuoteLineItemRepository quoteLineItemRepository;
private final OrderItemRepository orderItemRepository;
private final PublicMediaQueryService publicMediaQueryService;
private final AdminMediaControllerService adminMediaControllerService;
private final ShopStorageService shopStorageService;
private final SlicerService slicerService;
private final ClamAVService clamAVService;
private final long maxModelFileSizeBytes;
public AdminShopProductControllerService(ShopProductRepository shopProductRepository,
ShopCategoryRepository shopCategoryRepository,
ShopProductVariantRepository shopProductVariantRepository,
ShopProductModelAssetRepository shopProductModelAssetRepository,
QuoteLineItemRepository quoteLineItemRepository,
OrderItemRepository orderItemRepository,
PublicMediaQueryService publicMediaQueryService,
AdminMediaControllerService adminMediaControllerService,
ShopStorageService shopStorageService,
SlicerService slicerService,
ClamAVService clamAVService,
@Value("${shop.model.max-file-size-bytes:104857600}") long maxModelFileSizeBytes) {
this.shopProductRepository = shopProductRepository;
this.shopCategoryRepository = shopCategoryRepository;
this.shopProductVariantRepository = shopProductVariantRepository;
this.shopProductModelAssetRepository = shopProductModelAssetRepository;
this.quoteLineItemRepository = quoteLineItemRepository;
this.orderItemRepository = orderItemRepository;
this.publicMediaQueryService = publicMediaQueryService;
this.adminMediaControllerService = adminMediaControllerService;
this.shopStorageService = shopStorageService;
this.slicerService = slicerService;
this.clamAVService = clamAVService;
this.maxModelFileSizeBytes = maxModelFileSizeBytes;
}
public List<AdminShopProductDto> getProducts() {
return toProductDtos(shopProductRepository.findAllByOrderByIsFeaturedDescSortOrderAscNameAsc());
}
public AdminShopProductDto getProduct(UUID productId) {
ShopProduct product = shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
return toProductDtos(List.of(product)).get(0);
}
@Transactional
public AdminShopProductDto createProduct(AdminUpsertShopProductRequest payload) {
ensurePayload(payload);
LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, null);
ShopProduct product = new ShopProduct();
product.setCreatedAt(OffsetDateTime.now());
applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId()));
ShopProduct saved = shopProductRepository.save(product);
syncVariants(saved, payload.getVariants());
return getProduct(saved.getId());
}
@Transactional
public AdminShopProductDto updateProduct(UUID productId, AdminUpsertShopProductRequest payload) {
ensurePayload(payload);
ShopProduct product = shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, productId);
applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId()));
ShopProduct saved = shopProductRepository.save(product);
syncVariants(saved, payload.getVariants());
return getProduct(saved.getId());
}
@Transactional
public void deleteProduct(UUID productId) {
ShopProduct product = shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
if (quoteLineItemRepository.existsByShopProduct_Id(productId)
|| orderItemRepository.existsByShopProduct_Id(productId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Product is already used in carts or orders and cannot be deleted");
}
List<ShopProductVariant> variants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId);
for (ShopProductVariant variant : variants) {
if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId())
|| orderItemRepository.existsByShopProductVariant_Id(variant.getId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "One or more variants are already used in carts or orders and cannot be deleted");
}
}
shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> {
deleteExistingModelFile(asset, productId);
shopProductModelAssetRepository.delete(asset);
});
if (!variants.isEmpty()) {
shopProductVariantRepository.deleteAll(variants);
}
shopProductRepository.delete(product);
}
@Transactional
public AdminShopProductDto uploadProductModel(UUID productId, MultipartFile file) throws IOException {
ShopProduct product = shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
validateModelUpload(file);
Path tempDirectory = Files.createTempDirectory("shop-product-model-");
Path destination = null;
try {
String cleanedFilename = sanitizeOriginalFilename(file.getOriginalFilename());
String extension = resolveExtension(cleanedFilename);
Path uploadPath = tempDirectory.resolve("upload." + extension);
file.transferTo(uploadPath);
try (InputStream inputStream = Files.newInputStream(uploadPath)) {
clamAVService.scan(inputStream);
}
Path storageDir = shopStorageService.productModelStorageDir(productId);
destination = storageDir.resolve(UUID.randomUUID() + ".stl");
if ("3mf".equals(extension)) {
slicerService.convert3mfToPersistentStl(uploadPath.toFile(), destination);
} else {
Files.copy(uploadPath, destination, StandardCopyOption.REPLACE_EXISTING);
}
ModelDimensions dimensions = slicerService.inspectModelDimensions(destination.toFile())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unable to extract model dimensions"));
ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId)
.orElseGet(ShopProductModelAsset::new);
String previousStoredRelativePath = asset.getStoredRelativePath();
asset.setProduct(product);
asset.setOriginalFilename(buildDownloadFilename(cleanedFilename));
asset.setStoredFilename(destination.getFileName().toString());
asset.setStoredRelativePath(shopStorageService.toStoredPath(destination));
asset.setMimeType("model/stl");
asset.setFileSizeBytes(Files.size(destination));
asset.setSha256Hex(computeSha256(destination));
asset.setBoundingBoxXMm(BigDecimal.valueOf(dimensions.xMm()));
asset.setBoundingBoxYMm(BigDecimal.valueOf(dimensions.yMm()));
asset.setBoundingBoxZMm(BigDecimal.valueOf(dimensions.zMm()));
if (asset.getCreatedAt() == null) {
asset.setCreatedAt(OffsetDateTime.now());
}
asset.setUpdatedAt(OffsetDateTime.now());
shopProductModelAssetRepository.save(asset);
deleteStoredRelativePath(previousStoredRelativePath, productId, asset.getStoredRelativePath());
return getProduct(productId);
} catch (IOException | RuntimeException e) {
deletePathQuietly(destination);
throw e;
} finally {
deleteRecursively(tempDirectory);
}
}
@Transactional
public void deleteProductModel(UUID productId) {
shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found"));
deleteExistingModelFile(asset, productId);
shopProductModelAssetRepository.delete(asset);
}
public ProductModelDownload getProductModel(UUID productId) {
ShopProduct product = shopProductRepository.findById(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found"));
ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found"));
Path path = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), product.getId());
if (path == null || !Files.exists(path)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found");
}
return new ProductModelDownload(path, asset.getOriginalFilename(), asset.getMimeType());
}
private void syncVariants(ShopProduct product, List<AdminUpsertShopProductVariantRequest> variantPayloads) {
List<AdminUpsertShopProductVariantRequest> normalizedPayloads = normalizeVariantPayloads(variantPayloads);
List<ShopProductVariant> existingVariants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(product.getId());
Map<UUID, ShopProductVariant> existingById = existingVariants.stream()
.collect(Collectors.toMap(ShopProductVariant::getId, variant -> variant, (left, right) -> left, LinkedHashMap::new));
Set<UUID> retainedIds = new LinkedHashSet<>();
List<ShopProductVariant> variantsToSave = new ArrayList<>();
for (AdminUpsertShopProductVariantRequest payload : normalizedPayloads) {
ShopProductVariant variant;
if (payload.getId() != null) {
variant = existingById.get(payload.getId());
if (variant == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant does not belong to the product");
}
retainedIds.add(variant.getId());
} else {
variant = new ShopProductVariant();
variant.setCreatedAt(OffsetDateTime.now());
}
applyVariantPayload(variant, product, payload);
variantsToSave.add(variant);
}
List<ShopProductVariant> variantsToDelete = existingVariants.stream()
.filter(variant -> !retainedIds.contains(variant.getId()))
.toList();
for (ShopProductVariant variant : variantsToDelete) {
if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId())
|| orderItemRepository.existsByShopProductVariant_Id(variant.getId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Variant is already used in carts or orders and cannot be removed");
}
}
if (!variantsToDelete.isEmpty()) {
shopProductVariantRepository.deleteAll(variantsToDelete);
}
shopProductVariantRepository.saveAll(variantsToSave);
}
private void applyProductPayload(ShopProduct product,
AdminUpsertShopProductRequest payload,
LocalizedProductContent localizedContent,
String normalizedSlug,
ShopCategory category) {
product.setCategory(category);
product.setSlug(normalizedSlug);
product.setName(localizedContent.defaultName());
product.setNameIt(localizedContent.names().get("it"));
product.setNameEn(localizedContent.names().get("en"));
product.setNameDe(localizedContent.names().get("de"));
product.setNameFr(localizedContent.names().get("fr"));
product.setExcerpt(localizedContent.defaultExcerpt());
product.setExcerptIt(localizedContent.excerpts().get("it"));
product.setExcerptEn(localizedContent.excerpts().get("en"));
product.setExcerptDe(localizedContent.excerpts().get("de"));
product.setExcerptFr(localizedContent.excerpts().get("fr"));
product.setDescription(localizedContent.defaultDescription());
product.setDescriptionIt(localizedContent.descriptions().get("it"));
product.setDescriptionEn(localizedContent.descriptions().get("en"));
product.setDescriptionDe(localizedContent.descriptions().get("de"));
product.setDescriptionFr(localizedContent.descriptions().get("fr"));
product.setSeoTitle(localizedContent.defaultSeoTitle());
product.setSeoTitleIt(localizedContent.seoTitles().get("it"));
product.setSeoTitleEn(localizedContent.seoTitles().get("en"));
product.setSeoTitleDe(localizedContent.seoTitles().get("de"));
product.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
product.setSeoDescription(localizedContent.defaultSeoDescription());
product.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
product.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
product.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
product.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
product.setOgTitle(localizedContent.defaultSeoTitle());
product.setOgDescription(localizedContent.defaultSeoDescription());
product.setIndexable(payload.getIndexable() == null || payload.getIndexable());
product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured()));
product.setIsActive(payload.getIsActive() == null || payload.getIsActive());
product.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0);
product.setUpdatedAt(OffsetDateTime.now());
}
private void applyVariantPayload(ShopProductVariant variant,
ShopProduct product,
AdminUpsertShopProductVariantRequest payload) {
String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
String normalizedSku = normalizeOptional(payload.getSku());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
String normalizedMaterialCode = normalizeRequired(
payload.getInternalMaterialCode(),
"Variant internalMaterialCode is required"
).toUpperCase(Locale.ROOT);
BigDecimal price = payload.getPriceChf();
if (price == null || price.compareTo(BigDecimal.ZERO) < 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must be >= 0");
}
if (price.scale() > 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must have at most 2 decimal places");
}
if (normalizedSku != null) {
if (variant.getId() == null) {
if (shopProductVariantRepository.existsBySkuIgnoreCase(normalizedSku)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists");
}
} else if (shopProductVariantRepository.existsBySkuIgnoreCaseAndIdNot(normalizedSku, variant.getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists");
}
}
variant.setProduct(product);
variant.setSku(normalizedSku);
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName);
variant.setColorName(normalizedColorName);
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
variant.setColorHex(normalizeColorHex(payload.getColorHex()));
variant.setInternalMaterialCode(normalizedMaterialCode);
variant.setPriceChf(price);
variant.setIsDefault(Boolean.TRUE.equals(payload.getIsDefault()));
variant.setIsActive(payload.getIsActive() == null || payload.getIsActive());
variant.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0);
variant.setUpdatedAt(OffsetDateTime.now());
}
private List<AdminUpsertShopProductVariantRequest> normalizeVariantPayloads(List<AdminUpsertShopProductVariantRequest> payloads) {
if (payloads == null || payloads.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one variant is required");
}
List<AdminUpsertShopProductVariantRequest> normalized = new ArrayList<>(payloads);
Set<String> variantKeys = new LinkedHashSet<>();
int defaultCount = 0;
for (AdminUpsertShopProductVariantRequest payload : normalized) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required");
}
String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String materialCode = normalizeRequired(
payload.getInternalMaterialCode(),
"Variant internalMaterialCode is required"
).toUpperCase(Locale.ROOT);
String variantKey = materialCode + "|" + colorName.toLowerCase(Locale.ROOT);
if (!variantKeys.add(variantKey)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Duplicate variant combination: " + materialCode + " / " + colorName
);
}
if (Boolean.TRUE.equals(payload.getIsDefault())) {
defaultCount++;
}
}
if (defaultCount > 1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only one variant can be default");
}
if (defaultCount == 0) {
AdminUpsertShopProductVariantRequest fallbackDefault = normalized.stream()
.filter(payload -> payload.getIsActive() == null || payload.getIsActive())
.findFirst()
.orElse(normalized.get(0));
fallbackDefault.setIsDefault(true);
}
return normalized;
}
private List<AdminShopProductDto> toProductDtos(List<ShopProduct> products) {
if (products == null || products.isEmpty()) {
return List.of();
}
List<UUID> productIds = products.stream().map(ShopProduct::getId).toList();
Map<UUID, List<ShopProductVariant>> variantsByProductId = shopProductVariantRepository
.findByProduct_IdInOrderBySortOrderAscColorNameAsc(productIds)
.stream()
.collect(Collectors.groupingBy(
variant -> variant.getProduct().getId(),
LinkedHashMap::new,
Collectors.toList()
));
Map<UUID, ShopProductModelAsset> modelAssetsByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds)
.stream()
.collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new));
Map<String, List<PublicMediaUsageDto>> publicImagesByUsageKey = publicMediaQueryService.getUsageMediaMap(
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
products.stream().map(this::mediaUsageKey).toList(),
null
);
return products.stream()
.map(product -> {
String usageKey = mediaUsageKey(product);
return toProductDto(
product,
variantsByProductId.getOrDefault(product.getId(), List.of()),
modelAssetsByProductId.get(product.getId()),
publicImagesByUsageKey.getOrDefault(usageKey, List.of()),
adminMediaControllerService.getUsages(SHOP_PRODUCT_MEDIA_USAGE_TYPE, usageKey, null)
);
})
.toList();
}
private AdminShopProductDto toProductDto(ShopProduct product,
List<ShopProductVariant> variants,
ShopProductModelAsset modelAsset,
List<PublicMediaUsageDto> images,
List<AdminMediaUsageDto> mediaUsages) {
AdminShopProductDto dto = new AdminShopProductDto();
dto.setId(product.getId());
dto.setCategoryId(product.getCategory() != null ? product.getCategory().getId() : null);
dto.setCategoryName(product.getCategory() != null ? product.getCategory().getName() : null);
dto.setCategorySlug(product.getCategory() != null ? product.getCategory().getSlug() : null);
dto.setSlug(product.getSlug());
dto.setName(product.getName());
dto.setNameIt(product.getNameIt());
dto.setNameEn(product.getNameEn());
dto.setNameDe(product.getNameDe());
dto.setNameFr(product.getNameFr());
dto.setExcerpt(product.getExcerpt());
dto.setExcerptIt(product.getExcerptIt());
dto.setExcerptEn(product.getExcerptEn());
dto.setExcerptDe(product.getExcerptDe());
dto.setExcerptFr(product.getExcerptFr());
dto.setDescription(product.getDescription());
dto.setDescriptionIt(product.getDescriptionIt());
dto.setDescriptionEn(product.getDescriptionEn());
dto.setDescriptionDe(product.getDescriptionDe());
dto.setDescriptionFr(product.getDescriptionFr());
dto.setSeoTitle(product.getSeoTitle());
dto.setSeoTitleIt(product.getSeoTitleIt());
dto.setSeoTitleEn(product.getSeoTitleEn());
dto.setSeoTitleDe(product.getSeoTitleDe());
dto.setSeoTitleFr(product.getSeoTitleFr());
dto.setSeoDescription(product.getSeoDescription());
dto.setSeoDescriptionIt(product.getSeoDescriptionIt());
dto.setSeoDescriptionEn(product.getSeoDescriptionEn());
dto.setSeoDescriptionDe(product.getSeoDescriptionDe());
dto.setSeoDescriptionFr(product.getSeoDescriptionFr());
dto.setOgTitle(product.getOgTitle());
dto.setOgDescription(product.getOgDescription());
dto.setIndexable(product.getIndexable());
dto.setIsFeatured(product.getIsFeatured());
dto.setIsActive(product.getIsActive());
dto.setSortOrder(product.getSortOrder());
dto.setVariantCount(variants.size());
dto.setActiveVariantCount((int) variants.stream().filter(variant -> Boolean.TRUE.equals(variant.getIsActive())).count());
dto.setPriceFromChf(resolvePriceFrom(variants));
dto.setPriceToChf(resolvePriceTo(variants));
dto.setMediaUsageType(SHOP_PRODUCT_MEDIA_USAGE_TYPE);
dto.setMediaUsageKey(mediaUsageKey(product));
dto.setMediaUsages(mediaUsages);
dto.setImages(images);
dto.setModel3d(toModelDto(product, modelAsset));
dto.setVariants(variants.stream().map(this::toVariantDto).toList());
dto.setCreatedAt(product.getCreatedAt());
dto.setUpdatedAt(product.getUpdatedAt());
return dto;
}
private AdminShopProductVariantDto toVariantDto(ShopProductVariant variant) {
AdminShopProductVariantDto dto = new AdminShopProductVariantDto();
dto.setId(variant.getId());
dto.setSku(variant.getSku());
dto.setVariantLabel(variant.getVariantLabel());
dto.setColorName(variant.getColorName());
dto.setColorLabelIt(variant.getColorLabelIt());
dto.setColorLabelEn(variant.getColorLabelEn());
dto.setColorLabelDe(variant.getColorLabelDe());
dto.setColorLabelFr(variant.getColorLabelFr());
dto.setColorHex(variant.getColorHex());
dto.setInternalMaterialCode(variant.getInternalMaterialCode());
dto.setPriceChf(variant.getPriceChf());
dto.setIsDefault(variant.getIsDefault());
dto.setIsActive(variant.getIsActive());
dto.setSortOrder(variant.getSortOrder());
dto.setCreatedAt(variant.getCreatedAt());
dto.setUpdatedAt(variant.getUpdatedAt());
return dto;
}
private ShopProductModelDto toModelDto(ShopProduct product, ShopProductModelAsset asset) {
if (asset == null) {
return null;
}
return new ShopProductModelDto(
"/api/admin/shop/products/" + product.getId() + "/model",
asset.getOriginalFilename(),
asset.getMimeType(),
asset.getFileSizeBytes(),
asset.getBoundingBoxXMm(),
asset.getBoundingBoxYMm(),
asset.getBoundingBoxZMm()
);
}
private BigDecimal resolvePriceFrom(List<ShopProductVariant> variants) {
return variants.stream()
.map(ShopProductVariant::getPriceChf)
.filter(Objects::nonNull)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
private BigDecimal resolvePriceTo(List<ShopProductVariant> variants) {
return variants.stream()
.map(ShopProductVariant::getPriceChf)
.filter(Objects::nonNull)
.max(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
private ShopCategory resolveCategory(UUID categoryId) {
if (categoryId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "categoryId is required");
}
return shopCategoryRepository.findById(categoryId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Category not found"));
}
private void ensurePayload(AdminUpsertShopProductRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required");
}
}
private LocalizedProductContent normalizeLocalizedProductContent(AdminUpsertShopProductRequest payload) {
String legacyName = normalizeOptional(payload.getName());
String fallbackName = firstNonBlank(
legacyName,
normalizeOptional(payload.getNameIt()),
normalizeOptional(payload.getNameEn()),
normalizeOptional(payload.getNameDe()),
normalizeOptional(payload.getNameFr())
);
if (fallbackName == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product name is required");
}
Map<String, String> names = new LinkedHashMap<>();
names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian product name is required"));
names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English product name is required"));
names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German product name is required"));
names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French product name is required"));
String fallbackExcerpt = firstNonBlank(
normalizeOptional(payload.getExcerpt()),
normalizeOptional(payload.getExcerptIt()),
normalizeOptional(payload.getExcerptEn()),
normalizeOptional(payload.getExcerptDe()),
normalizeOptional(payload.getExcerptFr())
);
Map<String, String> excerpts = new LinkedHashMap<>();
excerpts.put("it", firstNonBlank(normalizeOptional(payload.getExcerptIt()), fallbackExcerpt));
excerpts.put("en", firstNonBlank(normalizeOptional(payload.getExcerptEn()), fallbackExcerpt));
excerpts.put("de", firstNonBlank(normalizeOptional(payload.getExcerptDe()), fallbackExcerpt));
excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt));
String fallbackDescription = firstNonBlank(
normalizeRichTextOptional(payload.getDescription()),
normalizeRichTextOptional(payload.getDescriptionIt()),
normalizeRichTextOptional(payload.getDescriptionEn()),
normalizeRichTextOptional(payload.getDescriptionDe()),
normalizeRichTextOptional(payload.getDescriptionFr())
);
Map<String, String> descriptions = new LinkedHashMap<>();
descriptions.put("it", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionIt()), fallbackDescription));
descriptions.put("en", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionEn()), fallbackDescription));
descriptions.put("de", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionDe()), fallbackDescription));
descriptions.put("fr", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionFr()), fallbackDescription));
String fallbackSeoTitle = firstNonBlank(
normalizeOptional(payload.getSeoTitle()),
normalizeOptional(payload.getSeoTitleIt()),
normalizeOptional(payload.getSeoTitleEn()),
normalizeOptional(payload.getSeoTitleDe()),
normalizeOptional(payload.getSeoTitleFr())
);
Map<String, String> seoTitles = new LinkedHashMap<>();
seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle));
seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle));
seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle));
seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle));
String fallbackSeoDescription = firstNonBlank(
normalizeOptional(payload.getSeoDescription()),
normalizeOptional(payload.getSeoDescriptionIt()),
normalizeOptional(payload.getSeoDescriptionEn()),
normalizeOptional(payload.getSeoDescriptionDe()),
normalizeOptional(payload.getSeoDescriptionFr())
);
Map<String, String> seoDescriptions = new LinkedHashMap<>();
seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian"));
seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English"));
seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German"));
seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French"));
return new LocalizedProductContent(
names.get("it"),
firstNonBlank(excerpts.get("it"), fallbackExcerpt),
firstNonBlank(descriptions.get("it"), fallbackDescription),
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
names,
excerpts,
descriptions,
seoTitles,
seoDescriptions
);
}
private void ensureSlugAvailable(String slug, UUID currentProductId) {
shopProductRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> {
if (currentProductId == null || !existing.getId().equals(currentProductId)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product slug already exists");
}
});
}
private String normalizeRequired(String value, String message) {
String normalized = normalizeOptional(value);
if (normalized == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
return normalized;
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String normalizeRichTextOptional(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
String sanitized = Jsoup.clean(
normalized,
"",
PRODUCT_DESCRIPTION_SAFELIST,
new Document.OutputSettings().prettyPrint(false)
).trim();
if (sanitized.isBlank()) {
return null;
}
String plainText = Jsoup.parse(sanitized).text();
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
private String normalizeAndValidateSlug(String slug, String fallbackName) {
String source = normalizeOptional(slug);
if (source == null) {
source = fallbackName;
}
String normalized = Normalizer.normalize(source, Normalizer.Form.NFD);
normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll("");
normalized = normalized.toLowerCase(Locale.ROOT);
normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-");
normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll("");
if (normalized.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Slug is invalid");
}
return normalized;
}
private String normalizeColorHex(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant colorHex must be in format #RRGGBB");
}
return normalized.toUpperCase(Locale.ROOT);
}
private String validateSeoDescriptionLength(String value, String languageLabel) {
if (value != null && value.length() > 160) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
}
return value;
}
private void validateModelUpload(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required");
}
if (maxModelFileSizeBytes > 0 && file.getSize() > maxModelFileSizeBytes) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file exceeds size limit");
}
String extension = resolveExtension(sanitizeOriginalFilename(file.getOriginalFilename()));
if (!SUPPORTED_MODEL_EXTENSIONS.contains(extension)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported 3D model type. Allowed: stl, 3mf");
}
}
private String sanitizeOriginalFilename(String originalFilename) {
String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename);
int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned;
basename = basename.replace("\r", "_").replace("\n", "_");
return basename.isBlank() ? "model.stl" : basename;
}
private String resolveExtension(String filename) {
int dotIndex = filename.lastIndexOf('.');
if (dotIndex < 0 || dotIndex == filename.length() - 1) {
return "";
}
return filename.substring(dotIndex + 1).toLowerCase(Locale.ROOT);
}
private String buildDownloadFilename(String originalFilename) {
int dotIndex = originalFilename.lastIndexOf('.');
String base = dotIndex > 0 ? originalFilename.substring(0, dotIndex) : originalFilename;
return base + ".stl";
}
private String mediaUsageKey(ShopProduct product) {
return product.getId().toString();
}
private void deleteExistingModelFile(ShopProductModelAsset asset, UUID productId) {
if (asset == null || asset.getStoredRelativePath() == null || asset.getStoredRelativePath().isBlank()) {
return;
}
Path existingPath = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId);
if (existingPath == null) {
return;
}
try {
Files.deleteIfExists(existingPath);
} catch (IOException ignored) {
}
}
private void deleteStoredRelativePath(String storedRelativePath, UUID productId, String excludeStoredRelativePath) {
if (storedRelativePath == null || storedRelativePath.isBlank()) {
return;
}
if (Objects.equals(storedRelativePath, excludeStoredRelativePath)) {
return;
}
Path existingPath = shopStorageService.resolveStoredProductPath(storedRelativePath, productId);
deletePathQuietly(existingPath);
}
private String computeSha256(Path file) throws IOException {
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IOException("SHA-256 digest unavailable", e);
}
try (InputStream inputStream = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int read;
while ((read = inputStream.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
return HexFormat.of().formatHex(digest.digest());
}
private void deletePathQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
}
private void deleteRecursively(Path path) {
if (path == null || !Files.exists(path)) {
return;
}
try (var walk = Files.walk(path)) {
walk.sorted(Comparator.reverseOrder()).forEach(current -> {
try {
Files.deleteIfExists(current);
} catch (IOException ignored) {
}
});
} catch (IOException ignored) {
}
}
public record ProductModelDownload(Path path, String filename, String mimeType) {
}
private record LocalizedProductContent(
String defaultName,
String defaultExcerpt,
String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) {
}
}

View File

@@ -0,0 +1,685 @@
package com.printcalculator.service.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.repository.ShopCategoryRepository;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
@Transactional(readOnly = true)
public class AdminShopProductTranslationService {
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "http", "https", "mailto", "tel");
private static final String DEFAULT_SHOP_CONTEXT = """
3D fab is a Swiss-based 3D printing shop and technical service.
The tone must be practical, clear, technical, and trustworthy.
Avoid hype, avoid invented claims, and avoid vague marketing filler.
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
When the source field is empty, return an empty string rather than inventing content.
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
""";
private final ShopCategoryRepository shopCategoryRepository;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String apiKey;
private final String baseUrl;
private final String model;
private final Duration timeout;
private final String promptCacheKeyPrefix;
private final String additionalBusinessContext;
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
ObjectMapper objectMapper,
@Value("${openai.translation.api-key:}") String apiKey,
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
@Value("${openai.translation.model:gpt-5.4}") String model,
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
this.shopCategoryRepository = shopCategoryRepository;
this.objectMapper = objectMapper;
this.apiKey = apiKey != null ? apiKey.trim() : "";
this.baseUrl = normalizeBaseUrl(baseUrl);
this.model = model != null ? model.trim() : "";
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
? promptCacheKeyPrefix.trim()
: "printcalc-shop-product-translation-v1";
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
.build();
}
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
ensureConfigured();
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
if (targetLanguages.isEmpty()) {
return emptyResponse(normalizedRequest.sourceLanguage());
}
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
TranslationBundle generated = callOpenAiFunction(
"generate_product_translations",
"Generate translated product copy for the requested target languages.",
buildInstructions("Generate the first-pass translations.", businessContext),
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
buildTranslationToolSchema(targetLanguages),
"generate"
);
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
TranslationBundle reviewed = callOpenAiFunction(
"review_product_translations",
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
buildInstructions("Review and correct the generated translations.", businessContext),
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
buildTranslationToolSchema(targetLanguages),
"review"
);
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
ensureRequiredTranslations(finalBundle, targetLanguages);
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
}
private void ensureConfigured() {
if (apiKey.isBlank() || model.isBlank()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"OpenAI translation is not configured on the backend"
);
}
}
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
}
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
}
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
if (names.get(sourceLanguage).isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"The active source language must have a product name before translation"
);
}
Set<String> materialCodes = new LinkedHashSet<>();
if (payload.getMaterialCodes() != null) {
for (String materialCode : payload.getMaterialCodes()) {
String normalizedCode = normalizeOptional(materialCode);
if (normalizedCode != null) {
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
}
}
}
return new NormalizedTranslationRequest(
payload.getCategoryId(),
sourceLanguage,
Boolean.TRUE.equals(payload.getOverwriteExisting()),
List.copyOf(materialCodes),
names,
excerpts,
descriptions,
seoTitles,
seoDescriptions
);
}
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
List<String> targetLanguages = new ArrayList<>();
for (String language : SUPPORTED_LANGUAGES) {
if (language.equals(request.sourceLanguage())) {
continue;
}
if (request.overwriteExisting() || needsTranslation(request, language)) {
targetLanguages.add(language);
}
}
return targetLanguages;
}
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
return request.names().get(language).isBlank()
|| request.excerpts().get(language).isBlank()
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|| request.seoTitles().get(language).isBlank()
|| request.seoDescriptions().get(language).isBlank();
}
private CategoryContext loadCategoryContext(UUID categoryId) {
if (categoryId == null) {
return null;
}
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
if (category == null) {
return null;
}
return new CategoryContext(
category.getSlug(),
Map.of(
"it", safeValue(category.getNameIt()),
"en", safeValue(category.getNameEn()),
"de", safeValue(category.getNameDe()),
"fr", safeValue(category.getNameFr())
),
Map.of(
"it", safeValue(category.getDescriptionIt()),
"en", safeValue(category.getDescriptionEn()),
"de", safeValue(category.getDescriptionDe()),
"fr", safeValue(category.getDescriptionFr())
)
);
}
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
if (!additionalBusinessContext.isBlank()) {
context.append('\n').append(additionalBusinessContext.trim());
}
if (categoryContext != null) {
context.append("\nCategory slug: ").append(categoryContext.slug());
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
}
}
if (materialCodes != null && !materialCodes.isEmpty()) {
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
}
return context.toString();
}
private String buildInstructions(String task, String businessContext) {
return """
You are a senior ecommerce localization editor.
Task: %s
Return only the function call arguments that match the provided schema.
Always preserve meaning, HTML safety, and technical precision.
Never invent specifications or marketing claims not present in the source.
If a source field is empty, return an empty string for that field.
General context:
%s
""".formatted(task, businessContext);
}
private String buildGenerationInput(NormalizedTranslationRequest request,
List<String> targetLanguages,
CategoryContext categoryContext) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.put("overwriteExisting", request.overwriteExisting());
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private String buildReviewInput(NormalizedTranslationRequest request,
TranslationBundle generated,
List<String> targetLanguages,
CategoryContext categoryContext,
List<String> validationNotes) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
ObjectNode node = objectMapper.createObjectNode();
node.put("name", request.names().get(language));
node.put("excerpt", request.excerpts().get(language));
node.put("description", request.descriptions().get(language));
node.put("seoTitle", request.seoTitles().get(language));
node.put("seoDescription", request.seoDescriptions().get(language));
return node;
}
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
ObjectNode node = objectMapper.createObjectNode();
for (String language : targetLanguages) {
node.set(language, localizedFieldNode(request, language));
}
return node;
}
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
ObjectNode root = objectMapper.createObjectNode();
root.put("type", "object");
root.put("additionalProperties", false);
ObjectNode properties = root.putObject("properties");
ObjectNode translations = properties.putObject("translations");
translations.put("type", "object");
translations.put("additionalProperties", false);
ObjectNode translationProperties = translations.putObject("properties");
ArrayNode requiredTranslations = translations.putArray("required");
for (String language : targetLanguages) {
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
requiredTranslations.add(language);
}
ArrayNode required = root.putArray("required");
required.add("translations");
return root;
}
private ObjectNode buildTranslationSchemaForLanguage(String language) {
ObjectNode languageSchema = objectMapper.createObjectNode();
languageSchema.put("type", "object");
languageSchema.put("additionalProperties", false);
languageSchema.put("description", "Localized product copy for language " + language);
ObjectNode properties = languageSchema.putObject("properties");
addSchemaString(properties, "name", "Translated product name. Never empty.");
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
ArrayNode required = languageSchema.putArray("required");
required.add("name");
required.add("excerpt");
required.add("description");
required.add("seoTitle");
required.add("seoDescription");
return languageSchema;
}
private void addSchemaString(ObjectNode properties, String name, String description) {
ObjectNode property = properties.putObject(name);
property.put("type", "string");
property.put("description", description);
}
private TranslationBundle callOpenAiFunction(String functionName,
String functionDescription,
String instructions,
String input,
ObjectNode parametersSchema,
String cacheSuffix) {
ObjectNode requestPayload = objectMapper.createObjectNode();
requestPayload.put("model", model);
requestPayload.put("instructions", instructions);
requestPayload.put("input", input);
requestPayload.put("tool_choice", "required");
requestPayload.put("temperature", 0.2);
requestPayload.put("store", false);
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
ArrayNode tools = requestPayload.putArray("tools");
ObjectNode tool = tools.addObject();
tool.put("type", "function");
tool.put("name", functionName);
tool.put("description", functionDescription);
tool.put("strict", true);
tool.set("parameters", parametersSchema);
JsonNode responseNode = postResponsesRequest(requestPayload);
JsonNode output = responseNode.path("output");
if (output.isArray()) {
for (JsonNode item : output) {
if ("function_call".equals(item.path("type").asText())) {
String arguments = item.path("arguments").asText("");
if (arguments.isBlank()) {
break;
}
try {
JsonNode argumentsNode = objectMapper.readTree(arguments);
JsonNode translationsNode = argumentsNode.path("translations");
if (!translationsNode.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned a function call without translations"
);
}
return TranslationBundle.fromJson(translationsNode);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned invalid JSON arguments",
exception
);
}
}
}
}
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return the expected function call"
);
}
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/responses"))
.timeout(timeout)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body = readJson(response.body());
if (response.statusCode() >= 400) {
String message = body.path("error").path("message").asText("").trim();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
message.isBlank() ? "OpenAI translation request failed" : message
);
}
return body;
} catch (IOException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"Unable to read the OpenAI translation response",
exception
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"The OpenAI translation request was interrupted",
exception
);
}
}
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
List<String> notes = new ArrayList<>();
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
notes.add(language + ": translated name is empty and must be fixed");
}
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
if (seoDescription.length() > 160) {
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
}
String description = bundle.descriptions().getOrDefault(language, "");
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
notes.add(language + ": description lost meaningful text during sanitization");
}
}
if (notes.isEmpty()) {
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
}
return notes;
}
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
for (String language : targetLanguages) {
names.put(language, safeValue(bundle.names().get(language)));
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
}
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
);
}
}
}
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
List<String> targetLanguages,
TranslationBundle bundle) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(targetLanguages);
response.setNames(bundle.names());
response.setExcerpts(bundle.excerpts());
response.setDescriptions(bundle.descriptions());
response.setSeoTitles(bundle.seoTitles());
response.setSeoDescriptions(bundle.seoDescriptions());
return response;
}
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(List.of());
response.setNames(Map.of());
response.setExcerpts(Map.of());
response.setDescriptions(Map.of());
response.setSeoTitles(Map.of());
response.setSeoDescriptions(Map.of());
return response;
}
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
Map<String, String> normalized = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
String value = rawValues != null ? rawValues.get(language) : null;
if (richText) {
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
} else {
normalized.put(language, safeValue(value));
}
}
return normalized;
}
private String safeValue(String value) {
return value == null ? "" : value.trim();
}
private String safeDescription(String value) {
String normalized = normalizeRichTextOptional(value);
return normalized != null ? normalized : "";
}
private String limitSeoDescription(String value) {
String normalized = safeValue(value);
if (normalized.length() <= 160) {
return normalized;
}
int lastSpace = normalized.lastIndexOf(' ', 157);
if (lastSpace >= 120) {
return normalized.substring(0, lastSpace).trim();
}
return normalized.substring(0, 160).trim();
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String normalizeRichTextOptional(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
String sanitized = Jsoup.clean(
normalized,
"",
PRODUCT_DESCRIPTION_SAFELIST,
new Document.OutputSettings().prettyPrint(false)
).trim();
if (sanitized.isBlank()) {
return null;
}
String plainText = Jsoup.parse(sanitized).text();
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
}
private String normalizeBaseUrl(String rawBaseUrl) {
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
? rawBaseUrl.trim()
: "https://api.openai.com/v1";
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Unable to serialize translation payload",
exception
);
}
}
private JsonNode readJson(String rawJson) throws IOException {
return objectMapper.readTree(rawJson);
}
private record NormalizedTranslationRequest(UUID categoryId,
String sourceLanguage,
boolean overwriteExisting,
List<String> materialCodes,
Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
}
private record CategoryContext(String slug,
Map<String, String> names,
Map<String, String> descriptions) {
}
private record TranslationBundle(Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
static TranslationBundle fromJson(JsonNode translationsNode) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
translationsNode.fieldNames().forEachRemaining(language -> {
JsonNode localizedNode = translationsNode.path(language);
names.put(language, localizedNode.path("name").asText(""));
excerpts.put(language, localizedNode.path("excerpt").asText(""));
descriptions.put(language, localizedNode.path("description").asText(""));
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
});
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
ObjectNode toJsonNode(ObjectMapper objectMapper) {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode translations = root.putObject("translations");
for (String language : names.keySet()) {
ObjectNode languageNode = translations.putObject(language);
languageNode.put("name", names.getOrDefault(language, ""));
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
languageNode.put("description", descriptions.getOrDefault(language, ""));
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
}
return root;
}
}
}

View File

@@ -0,0 +1,347 @@
package com.printcalculator.service.media;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class MediaFfmpegService {
private static final Logger logger = LoggerFactory.getLogger(MediaFfmpegService.class);
private static final Map<String, List<String>> ENCODER_CANDIDATES = Map.of(
"JPEG", List.of("mjpeg"),
"WEBP", List.of("libwebp", "webp"),
"AVIF", List.of("libaom-av1", "librav1e", "libsvtav1")
);
private static final Map<String, List<String>> REQUIRED_MUXERS = Map.of(
"AVIF", List.of("avif")
);
private final String ffmpegExecutable;
private final Set<String> availableEncoders;
private final Set<String> availableMuxers;
public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) {
this.ffmpegExecutable = resolveExecutable(ffmpegPath);
this.availableEncoders = Collections.unmodifiableSet(loadAvailableEncoders());
this.availableMuxers = Collections.unmodifiableSet(loadAvailableMuxers());
}
public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException {
if (widthPx <= 0 || heightPx <= 0) {
throw new IllegalArgumentException("Variant dimensions must be positive.");
}
Path sourcePath = sanitizeMediaPath(source, "source", true);
Path targetPath = sanitizeMediaPath(target, "target", false);
Files.createDirectories(targetPath.getParent());
String normalizedFormat = normalizeFormat(format);
String encoder = resolveEncoder(normalizedFormat);
if (encoder == null) {
throw new IOException("FFmpeg encoder not available for media format " + normalizedFormat + ".");
}
if (!hasRequiredMuxer(normalizedFormat)) {
throw new IOException("FFmpeg muxer not available for media format " + normalizedFormat + ".");
}
List<String> command = new ArrayList<>();
command.add(ffmpegExecutable);
command.add("-y");
command.add("-hide_banner");
command.add("-loglevel");
command.add("error");
command.add("-i");
command.add(sourcePath.toString());
command.add("-vf");
command.add("scale=" + widthPx + ":" + heightPx + ":flags=lanczos,setsar=1");
command.add("-frames:v");
command.add("1");
command.add("-an");
switch (normalizedFormat) {
case "JPEG" -> {
command.add("-c:v");
command.add(encoder);
command.add("-q:v");
command.add("2");
}
case "WEBP" -> {
command.add("-c:v");
command.add(encoder);
command.add("-quality");
command.add("82");
}
case "AVIF" -> {
command.add("-c:v");
command.add(encoder);
command.add("-crf");
command.add("30");
command.add("-b:v");
command.add("0");
command.add("-f");
command.add("avif");
}
default -> throw new IllegalArgumentException("Unsupported media format: " + normalizedFormat);
}
command.add(targetPath.toString());
Process process = startValidatedProcess(command);
String output;
try (InputStream processStream = process.getInputStream()) {
output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8);
}
int exitCode;
try {
exitCode = process.waitFor();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("FFmpeg execution interrupted.", e);
}
if (exitCode != 0 || !Files.exists(targetPath) || Files.size(targetPath) == 0) {
throw new IOException("FFmpeg failed to generate media variant. " + truncate(output));
}
}
public boolean canEncode(String format) {
String normalizedFormat = normalizeFormat(format);
return resolveEncoder(normalizedFormat) != null && hasRequiredMuxer(normalizedFormat);
}
private String resolveEncoder(String format) {
String normalizedFormat = normalizeFormat(format);
if (normalizedFormat == null) {
return null;
}
List<String> candidates = ENCODER_CANDIDATES.get(normalizedFormat);
if (candidates == null) {
return null;
}
return candidates.stream()
.filter(availableEncoders::contains)
.findFirst()
.orElse(null);
}
private boolean hasRequiredMuxer(String format) {
List<String> requiredMuxers = REQUIRED_MUXERS.get(format);
if (requiredMuxers == null || requiredMuxers.isEmpty()) {
return true;
}
return requiredMuxers.stream().anyMatch(availableMuxers::contains);
}
private String normalizeFormat(String format) {
if (format == null) {
return null;
}
return format.trim().toUpperCase(Locale.ROOT);
}
private Set<String> loadAvailableEncoders() {
List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-encoders");
try {
Process process = startValidatedProcess(command);
String output;
try (InputStream processStream = process.getInputStream()) {
output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8);
}
int exitCode = process.waitFor();
if (exitCode != 0) {
logger.warn("Unable to inspect FFmpeg encoders. Falling back to empty encoder list.");
return Set.of();
}
return parseAvailableEncoders(output);
} catch (Exception e) {
logger.warn(
"Unable to inspect FFmpeg encoders for executable '{}'. Falling back to empty encoder list. {}",
ffmpegExecutable,
e.getMessage()
);
return Set.of();
}
}
private Set<String> loadAvailableMuxers() {
List<String> command = List.of(ffmpegExecutable, "-hide_banner", "-muxers");
try {
Process process = startValidatedProcess(command);
String output;
try (InputStream processStream = process.getInputStream()) {
output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8);
}
int exitCode = process.waitFor();
if (exitCode != 0) {
logger.warn("Unable to inspect FFmpeg muxers. Falling back to empty muxer list.");
return Set.of();
}
return parseAvailableMuxers(output);
} catch (Exception e) {
logger.warn(
"Unable to inspect FFmpeg muxers for executable '{}'. Falling back to empty muxer list. {}",
ffmpegExecutable,
e.getMessage()
);
return Set.of();
}
}
private Process startValidatedProcess(List<String> command) throws IOException {
// nosemgrep: java.lang.security.audit.command-injection-process-builder.command-injection-process-builder
return new ProcessBuilder(List.copyOf(command))
.redirectErrorStream(true)
.start();
}
static String sanitizeExecutable(String configuredExecutable) {
if (configuredExecutable == null) {
throw new IllegalArgumentException("media.ffmpeg.path must not be null.");
}
String candidate = configuredExecutable.trim();
if (candidate.isEmpty()) {
throw new IllegalArgumentException("media.ffmpeg.path must point to an FFmpeg executable.");
}
if (candidate.chars().anyMatch(Character::isISOControl)) {
throw new IllegalArgumentException("media.ffmpeg.path contains control characters.");
}
try {
Path executablePath = Path.of(candidate);
Path filename = executablePath.getFileName();
String executableName = filename == null ? candidate : filename.toString();
if (executableName.isBlank() || executableName.startsWith("-")) {
throw new IllegalArgumentException("media.ffmpeg.path must be an executable path, not an option.");
}
return executablePath.normalize().toString();
} catch (InvalidPathException e) {
throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e);
}
}
static String resolveExecutable(String configuredExecutable) {
String candidate = sanitizeExecutable(configuredExecutable);
try {
Path configuredPath = Path.of(candidate);
if (!configuredPath.isAbsolute()) {
return candidate;
}
if (Files.isExecutable(configuredPath)) {
return configuredPath.toString();
}
Path filename = configuredPath.getFileName();
String fallbackExecutable = filename == null ? null : filename.toString();
if (fallbackExecutable != null && !fallbackExecutable.isBlank()) {
logger.warn(
"Configured FFmpeg executable '{}' not found or not executable. Falling back to '{}' from PATH.",
configuredPath,
fallbackExecutable
);
return fallbackExecutable;
}
return candidate;
} catch (InvalidPathException e) {
throw new IllegalArgumentException("media.ffmpeg.path is not a valid executable path.", e);
}
}
private Path sanitizeMediaPath(Path path, String label, boolean requireExistingFile) throws IOException {
if (path == null) {
throw new IllegalArgumentException("Media " + label + " path is required.");
}
Path normalized = path.toAbsolutePath().normalize();
Path filename = normalized.getFileName();
if (filename == null || filename.toString().isBlank()) {
throw new IOException("Media " + label + " path must include a file name.");
}
if (filename.toString().startsWith("-")) {
throw new IOException("Media " + label + " file name must not start with '-'.");
}
if (requireExistingFile) {
if (!Files.isRegularFile(normalized) || !Files.isReadable(normalized)) {
throw new IOException("Media " + label + " file is not readable.");
}
} else if (normalized.getParent() == null) {
throw new IOException("Media " + label + " path must include a parent directory.");
}
return normalized;
}
private Set<String> parseAvailableEncoders(String output) {
if (output == null || output.isBlank()) {
return Set.of();
}
Set<String> encoders = new LinkedHashSet<>();
for (String line : output.split("\\R")) {
String trimmed = line.trim();
if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Encoders:")) {
continue;
}
if (trimmed.length() < 7) {
continue;
}
String[] parts = trimmed.split("\\s+", 3);
if (parts.length < 2) {
continue;
}
encoders.add(parts[1]);
}
return encoders;
}
private Set<String> parseAvailableMuxers(String output) {
if (output == null || output.isBlank()) {
return Set.of();
}
Set<String> muxers = new LinkedHashSet<>();
for (String line : output.split("\\R")) {
String trimmed = line.trim();
if (trimmed.isBlank() || trimmed.startsWith("--") || trimmed.startsWith("Muxers:")) {
continue;
}
String[] parts = trimmed.split("\\s+", 3);
if (parts.length < 2) {
continue;
}
muxers.add(parts[1]);
}
return muxers;
}
private String truncate(String output) {
if (output == null || output.isBlank()) {
return "";
}
String normalized = output.trim().replace('\n', ' ');
return normalized.length() <= 300 ? normalized : normalized.substring(0, 300);
}
}

View File

@@ -0,0 +1,122 @@
package com.printcalculator.service.media;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@Service
public class MediaImageInspector {
private static final byte[] PNG_SIGNATURE = new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
};
public ImageMetadata inspect(Path file) throws IOException {
try (InputStream inputStream = Files.newInputStream(file)) {
byte[] header = inputStream.readNBytes(64);
if (isJpeg(header)) {
return readWithImageIo(file, "image/jpeg", "jpg");
}
if (isPng(header)) {
return readWithImageIo(file, "image/png", "png");
}
if (isWebp(header)) {
Dimensions dimensions = readWebpDimensions(header);
return new ImageMetadata("image/webp", "webp", dimensions.width(), dimensions.height());
}
}
throw new IllegalArgumentException("Unsupported image type. Allowed: jpg, jpeg, png, webp.");
}
private ImageMetadata readWithImageIo(Path file, String mimeType, String extension) throws IOException {
BufferedImage image = ImageIO.read(file.toFile());
if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) {
throw new IllegalArgumentException("Uploaded image is invalid or unreadable.");
}
return new ImageMetadata(mimeType, extension, image.getWidth(), image.getHeight());
}
private boolean isJpeg(byte[] header) {
return header.length >= 3
&& (header[0] & 0xFF) == 0xFF
&& (header[1] & 0xFF) == 0xD8
&& (header[2] & 0xFF) == 0xFF;
}
private boolean isPng(byte[] header) {
if (header.length < PNG_SIGNATURE.length) {
return false;
}
for (int i = 0; i < PNG_SIGNATURE.length; i++) {
if (header[i] != PNG_SIGNATURE[i]) {
return false;
}
}
return true;
}
private boolean isWebp(byte[] header) {
return header.length >= 16
&& "RIFF".equals(ascii(header, 0, 4))
&& "WEBP".equals(ascii(header, 8, 4));
}
private Dimensions readWebpDimensions(byte[] header) {
if (header.length < 30) {
throw new IllegalArgumentException("Uploaded WebP image is invalid.");
}
String chunkType = ascii(header, 12, 4);
return switch (chunkType) {
case "VP8X" -> new Dimensions(
littleEndian24(header, 24) + 1,
littleEndian24(header, 27) + 1
);
case "VP8 " -> new Dimensions(
littleEndian16(header, 26) & 0x3FFF,
littleEndian16(header, 28) & 0x3FFF
);
case "VP8L" -> {
int packed = littleEndian32(header, 21);
int width = (packed & 0x3FFF) + 1;
int height = ((packed >> 14) & 0x3FFF) + 1;
yield new Dimensions(width, height);
}
default -> throw new IllegalArgumentException("Uploaded WebP image is invalid.");
};
}
private String ascii(byte[] header, int offset, int length) {
return new String(header, offset, length, StandardCharsets.US_ASCII);
}
private int littleEndian16(byte[] header, int offset) {
return (header[offset] & 0xFF) | ((header[offset + 1] & 0xFF) << 8);
}
private int littleEndian24(byte[] header, int offset) {
return (header[offset] & 0xFF)
| ((header[offset + 1] & 0xFF) << 8)
| ((header[offset + 2] & 0xFF) << 16);
}
private int littleEndian32(byte[] header, int offset) {
return (header[offset] & 0xFF)
| ((header[offset + 1] & 0xFF) << 8)
| ((header[offset + 2] & 0xFF) << 16)
| ((header[offset + 3] & 0xFF) << 24);
}
private record Dimensions(int width, int height) {
}
public record ImageMetadata(String mimeType, String fileExtension, int widthPx, int heightPx) {
}
}

View File

@@ -0,0 +1,145 @@
package com.printcalculator.service.media;
import com.printcalculator.exception.StorageException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
@Service
public class MediaStorageService {
private final Path normalizedRootLocation;
private final Path originalRootLocation;
private final Path publicRootLocation;
private final Path privateRootLocation;
private final String frontendBaseUrl;
public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot,
@Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8081}}") String frontendBaseUrl) {
this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize();
this.originalRootLocation = normalizedRootLocation.resolve("original").normalize();
this.publicRootLocation = normalizedRootLocation.resolve("public").normalize();
this.privateRootLocation = normalizedRootLocation.resolve("private").normalize();
this.frontendBaseUrl = frontendBaseUrl;
init();
}
public void init() {
try {
Files.createDirectories(originalRootLocation);
Files.createDirectories(publicRootLocation);
Files.createDirectories(privateRootLocation);
} catch (IOException e) {
throw new StorageException("Could not initialize media storage.", e);
}
}
public void storeOriginal(Path source, String storageKey) throws IOException {
copy(source, resolveOriginal(storageKey));
}
public void storePublic(Path source, String storageKey) throws IOException {
copy(source, resolvePublic(storageKey));
}
public void storePrivate(Path source, String storageKey) throws IOException {
copy(source, resolvePrivate(storageKey));
}
public void deleteGenerated(String visibility, String storageKey) throws IOException {
Files.deleteIfExists(resolve(resolveVariantRoot(normalizeVisibility(visibility)), storageKey));
}
public void moveGenerated(String storageKey, String fromVisibility, String toVisibility) throws IOException {
String normalizedFrom = normalizeVisibility(fromVisibility);
String normalizedTo = normalizeVisibility(toVisibility);
if (normalizedFrom.equals(normalizedTo)) {
return;
}
Path source = resolve(resolveVariantRoot(normalizedFrom), storageKey);
Path target = resolve(resolveVariantRoot(normalizedTo), storageKey);
Files.createDirectories(target.getParent());
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
}
public String buildPublicUrl(String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
return null;
}
String mediaBaseUrl = buildMediaBaseUrl();
String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey;
if (mediaBaseUrl.endsWith("/")) {
return mediaBaseUrl + normalizedKey;
}
return mediaBaseUrl + "/" + normalizedKey;
}
private void copy(Path source, Path destination) throws IOException {
Files.createDirectories(destination.getParent());
Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING);
}
private Path resolveOriginal(String storageKey) {
return resolve(originalRootLocation, storageKey);
}
private Path resolvePublic(String storageKey) {
return resolve(publicRootLocation, storageKey);
}
private Path resolvePrivate(String storageKey) {
return resolve(privateRootLocation, storageKey);
}
private Path resolveVariantRoot(String visibility) {
return switch (visibility) {
case "PUBLIC" -> publicRootLocation;
case "PRIVATE" -> privateRootLocation;
default -> throw new StorageException("Unsupported media visibility: " + visibility);
};
}
private Path resolve(Path baseRoot, String storageKey) {
if (storageKey == null || storageKey.isBlank()) {
throw new StorageException("Storage key is required.");
}
Path relativePath = Paths.get(storageKey).normalize();
if (relativePath.isAbsolute()) {
throw new StorageException("Absolute paths are not allowed.");
}
Path resolved = baseRoot.resolve(relativePath).normalize();
if (!resolved.startsWith(baseRoot)) {
throw new StorageException("Cannot access files outside media storage root.");
}
return resolved;
}
private String normalizeVisibility(String visibility) {
if (visibility == null || visibility.isBlank()) {
throw new StorageException("Visibility is required.");
}
return visibility.trim().toUpperCase(Locale.ROOT);
}
private String buildMediaBaseUrl() {
String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : "";
if (normalized.contains("localhost")){
return "http://localhost:8081";
}
if (normalized.isBlank()) {
normalized = "http://localhost:8081";
}
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized + "/media";
}
}

View File

@@ -0,0 +1,192 @@
package com.printcalculator.service.media;
import com.printcalculator.dto.PublicMediaUsageDto;
import com.printcalculator.dto.PublicMediaVariantDto;
import com.printcalculator.entity.MediaAsset;
import com.printcalculator.entity.MediaUsage;
import com.printcalculator.entity.MediaVariant;
import com.printcalculator.repository.MediaUsageRepository;
import com.printcalculator.repository.MediaVariantRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class PublicMediaQueryService {
private static final String STATUS_READY = "READY";
private static final String VISIBILITY_PUBLIC = "PUBLIC";
private static final String FORMAT_JPEG = "JPEG";
private static final String FORMAT_WEBP = "WEBP";
private static final String FORMAT_AVIF = "AVIF";
private static final List<String> SUPPORTED_MEDIA_LANGUAGES = List.of("it", "en", "de", "fr");
private final MediaUsageRepository mediaUsageRepository;
private final MediaVariantRepository mediaVariantRepository;
private final MediaStorageService mediaStorageService;
public PublicMediaQueryService(MediaUsageRepository mediaUsageRepository,
MediaVariantRepository mediaVariantRepository,
MediaStorageService mediaStorageService) {
this.mediaUsageRepository = mediaUsageRepository;
this.mediaVariantRepository = mediaVariantRepository;
this.mediaStorageService = mediaStorageService;
}
public List<PublicMediaUsageDto> getUsageMedia(String usageType, String usageKey, String language) {
String normalizedUsageType = normalizeUsageType(usageType);
String normalizedUsageKey = normalizeUsageKey(usageKey);
return getUsageMediaMap(normalizedUsageType, List.of(normalizedUsageKey), language)
.getOrDefault(normalizedUsageKey, List.of());
}
public Map<String, List<PublicMediaUsageDto>> getUsageMediaMap(String usageType,
List<String> usageKeys,
String language) {
String normalizedUsageType = normalizeUsageType(usageType);
String normalizedLanguage = normalizeLanguage(language);
List<String> normalizedUsageKeys = (usageKeys == null
? List.<String>of()
: usageKeys)
.stream()
.filter(Objects::nonNull)
.map(this::normalizeUsageKey)
.distinct()
.toList();
if (normalizedUsageKeys.isEmpty()) {
return Map.of();
}
List<MediaUsage> usages = mediaUsageRepository
.findActiveByUsageTypeAndUsageKeys(normalizedUsageType, normalizedUsageKeys)
.stream()
.filter(this::isPublicReadyUsage)
.sorted(Comparator
.comparing(MediaUsage::getUsageKey, Comparator.nullsLast(String::compareTo))
.thenComparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)))
.toList();
if (usages.isEmpty()) {
return Map.of();
}
List<UUID> assetIds = usages.stream()
.map(MediaUsage::getMediaAsset)
.filter(Objects::nonNull)
.map(MediaAsset::getId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, List<MediaVariant>> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds)
.stream()
.filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat()))
.collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId()));
Map<String, List<PublicMediaUsageDto>> result = new LinkedHashMap<>();
for (MediaUsage usage : usages) {
result.computeIfAbsent(usage.getUsageKey(), ignored -> new java.util.ArrayList<>())
.add(toDto(
usage,
variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()),
normalizedLanguage
));
}
return result;
}
private boolean isPublicReadyUsage(MediaUsage usage) {
MediaAsset asset = usage.getMediaAsset();
return asset != null
&& STATUS_READY.equals(asset.getStatus())
&& VISIBILITY_PUBLIC.equals(asset.getVisibility());
}
private PublicMediaUsageDto toDto(MediaUsage usage, List<MediaVariant> variants, String language) {
Map<String, Map<String, MediaVariant>> variantsByPresetAndFormat = variants.stream()
.collect(Collectors.groupingBy(
MediaVariant::getVariantName,
Collectors.toMap(MediaVariant::getFormat, Function.identity(), (left, right) -> right)
));
PublicMediaUsageDto dto = new PublicMediaUsageDto();
dto.setMediaAssetId(usage.getMediaAsset().getId());
dto.setTitle(resolveLocalizedValue(usage.getTitleForLanguage(language), usage.getMediaAsset().getTitle()));
dto.setAltText(resolveLocalizedValue(usage.getAltTextForLanguage(language), usage.getMediaAsset().getAltText()));
dto.setUsageType(usage.getUsageType());
dto.setUsageKey(usage.getUsageKey());
dto.setSortOrder(usage.getSortOrder());
dto.setIsPrimary(usage.getIsPrimary());
dto.setThumb(buildPresetDto(variantsByPresetAndFormat.get("thumb")));
dto.setCard(buildPresetDto(variantsByPresetAndFormat.get("card")));
dto.setHero(buildPresetDto(variantsByPresetAndFormat.get("hero")));
return dto;
}
private PublicMediaVariantDto buildPresetDto(Map<String, MediaVariant> variantsByFormat) {
PublicMediaVariantDto dto = new PublicMediaVariantDto();
if (variantsByFormat == null || variantsByFormat.isEmpty()) {
return dto;
}
dto.setAvifUrl(buildVariantUrl(variantsByFormat.get(FORMAT_AVIF)));
dto.setWebpUrl(buildVariantUrl(variantsByFormat.get(FORMAT_WEBP)));
dto.setJpegUrl(buildVariantUrl(variantsByFormat.get(FORMAT_JPEG)));
return dto;
}
private String buildVariantUrl(MediaVariant variant) {
if (variant == null || variant.getStorageKey() == null || variant.getStorageKey().isBlank()) {
return null;
}
return mediaStorageService.buildPublicUrl(variant.getStorageKey());
}
private String normalizeUsageType(String usageType) {
if (usageType == null || usageType.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required.");
}
return usageType.trim().toUpperCase(Locale.ROOT);
}
private String normalizeUsageKey(String usageKey) {
if (usageKey == null || usageKey.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required.");
}
return usageKey.trim();
}
private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
return SUPPORTED_MEDIA_LANGUAGES.contains(normalized) ? normalized : "it";
}
private String resolveLocalizedValue(String preferred, String fallback) {
if (preferred != null && !preferred.isBlank()) {
return preferred;
}
if (fallback != null && !fallback.isBlank()) {
return fallback.trim();
}
return null;
}
}

View File

@@ -197,6 +197,7 @@ public class AdminOrderControllerService {
OrderDto dto = new OrderDto(); OrderDto dto = new OrderDto();
dto.setId(order.getId()); dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order)); dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR");
dto.setStatus(order.getStatus()); dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
@@ -260,13 +261,38 @@ public class AdminOrderControllerService {
List<OrderItemDto> itemDtos = items.stream().map(item -> { List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto(); OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId()); itemDto.setId(item.getId());
itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE");
itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setDisplayName(
item.getDisplayName() != null && !item.getDisplayName().isBlank()
? item.getDisplayName()
: item.getOriginalFilename()
);
itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode()); itemDto.setColorCode(item.getColorCode());
if (item.getShopProduct() != null) {
itemDto.setShopProductId(item.getShopProduct().getId());
}
if (item.getShopProductVariant() != null) {
itemDto.setShopProductVariantId(item.getShopProductVariant().getId());
}
itemDto.setShopProductSlug(item.getShopProductSlug());
itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName());
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -255,6 +255,7 @@ public class OrderControllerService {
OrderDto dto = new OrderDto(); OrderDto dto = new OrderDto();
dto.setId(order.getId()); dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order)); dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR");
dto.setStatus(order.getStatus()); dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> {
@@ -314,13 +315,38 @@ public class OrderControllerService {
List<OrderItemDto> itemDtos = items.stream().map(item -> { List<OrderItemDto> itemDtos = items.stream().map(item -> {
OrderItemDto itemDto = new OrderItemDto(); OrderItemDto itemDto = new OrderItemDto();
itemDto.setId(item.getId()); itemDto.setId(item.getId());
itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE");
itemDto.setOriginalFilename(item.getOriginalFilename()); itemDto.setOriginalFilename(item.getOriginalFilename());
itemDto.setDisplayName(
item.getDisplayName() != null && !item.getDisplayName().isBlank()
? item.getDisplayName()
: item.getOriginalFilename()
);
itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setMaterialCode(item.getMaterialCode());
itemDto.setColorCode(item.getColorCode()); itemDto.setColorCode(item.getColorCode());
if (item.getShopProduct() != null) {
itemDto.setShopProductId(item.getShopProduct().getId());
}
if (item.getShopProductVariant() != null) {
itemDto.setShopProductVariantId(item.getShopProductVariant().getId());
}
itemDto.setShopProductSlug(item.getShopProductSlug());
itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName());
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -88,14 +88,9 @@ public class InvoicePdfRenderingService {
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
} }
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> { List<Map<String, Object>> invoiceLineItems = items.stream()
Map<String, Object> line = new HashMap<>(); .map(this::toInvoiceLineItem)
line.put("description", "Stampa 3D: " + i.getOriginalFilename()); .collect(Collectors.toList());
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());
if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) { if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) {
BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO; BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO;
@@ -157,4 +152,45 @@ public class InvoicePdfRenderingService {
private String formatCadHours(BigDecimal hours) { private String formatCadHours(BigDecimal hours) {
return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
} }
private Map<String, Object> toInvoiceLineItem(OrderItem item) {
Map<String, Object> line = new HashMap<>();
line.put("description", buildLineDescription(item));
line.put("quantity", item.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", item.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", item.getLineTotalChf()));
return line;
}
private String buildLineDescription(OrderItem item) {
if (item == null) {
return "Articolo";
}
if ("SHOP_PRODUCT".equalsIgnoreCase(item.getItemType())) {
String productName = firstNonBlank(
item.getDisplayName(),
item.getShopProductName(),
item.getOriginalFilename(),
"Prodotto shop"
);
String variantLabel = firstNonBlank(item.getShopVariantLabel(), item.getShopVariantColorName(), null);
return variantLabel != null ? productName + " - " + variantLabel : productName;
}
String fileName = firstNonBlank(item.getDisplayName(), item.getOriginalFilename(), "File 3D");
return "Stampa 3D: " + fileName;
}
private String firstNonBlank(String... values) {
if (values == null || values.length == 0) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
} }

View File

@@ -72,10 +72,14 @@ public class QuoteSessionItemService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
} }
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "");
if (ext.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId()); Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
String storedFilename = UUID.randomUUID() + "." + ext; String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename); Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
@@ -237,7 +241,9 @@ public class QuoteSessionItemService {
Path convertedPersistentPath) { Path convertedPersistentPath) {
QuoteLineItem item = new QuoteLineItem(); QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session); item.setQuoteSession(session);
item.setLineItemType("PRINT_FILE");
item.setOriginalFilename(originalFilename); item.setOriginalFilename(originalFilename);
item.setDisplayName(originalFilename);
item.setStoredPath(quoteStorageService.toStoredPath(persistentPath)); item.setStoredPath(quoteStorageService.toStoredPath(persistentPath));
item.setQuantity(normalizeQuantity(settings.getQuantity())); item.setQuantity(normalizeQuantity(settings.getQuantity()));
item.setColorCode(selectedVariant.getColorName()); item.setColorCode(selectedVariant.getColorName());

View File

@@ -43,15 +43,53 @@ public class QuoteSessionResponseAssembler {
return response; return response;
} }
public Map<String, Object> emptyCart() {
Map<String, Object> response = new HashMap<>();
response.put("session", null);
response.put("items", List.of());
response.put("printItemsTotalChf", BigDecimal.ZERO);
response.put("cadTotalChf", BigDecimal.ZERO);
response.put("itemsTotalChf", BigDecimal.ZERO);
response.put("baseSetupCostChf", BigDecimal.ZERO);
response.put("nozzleChangeCostChf", BigDecimal.ZERO);
response.put("setupCostChf", BigDecimal.ZERO);
response.put("shippingCostChf", BigDecimal.ZERO);
response.put("globalMachineCostChf", BigDecimal.ZERO);
response.put("grandTotalChf", BigDecimal.ZERO);
return response;
}
private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) { private Map<String, Object> toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) {
Map<String, Object> dto = new HashMap<>(); Map<String, Object> dto = new HashMap<>();
dto.put("id", item.getId()); dto.put("id", item.getId());
dto.put("lineItemType", item.getLineItemType() != null ? item.getLineItemType() : "PRINT_FILE");
dto.put("originalFilename", item.getOriginalFilename()); dto.put("originalFilename", item.getOriginalFilename());
dto.put(
"displayName",
item.getDisplayName() != null && !item.getDisplayName().isBlank()
? item.getDisplayName()
: item.getOriginalFilename()
);
dto.put("quantity", item.getQuantity()); dto.put("quantity", item.getQuantity());
dto.put("printTimeSeconds", item.getPrintTimeSeconds()); dto.put("printTimeSeconds", item.getPrintTimeSeconds());
dto.put("materialGrams", item.getMaterialGrams()); dto.put("materialGrams", item.getMaterialGrams());
dto.put("colorCode", item.getColorCode()); dto.put("colorCode", item.getColorCode());
dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null);
dto.put("shopProductId", item.getShopProduct() != null ? item.getShopProduct().getId() : null);
dto.put("shopProductVariantId", item.getShopProductVariant() != null ? item.getShopProductVariant().getId() : null);
dto.put("shopProductSlug", item.getShopProductSlug());
dto.put("shopProductName", item.getShopProductName());
dto.put("shopVariantLabel", item.getShopVariantLabel());
dto.put("shopVariantColorName", item.getShopVariantColorName());
dto.put("shopVariantColorLabelIt", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
dto.put("shopVariantColorLabelEn", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
dto.put("shopVariantColorLabelDe", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
dto.put("shopVariantColorLabelFr", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
dto.put("shopVariantColorHex", item.getShopVariantColorHex());
dto.put("filamentColorLabelIt", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelIt() : null);
dto.put("filamentColorLabelEn", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelEn() : null);
dto.put("filamentColorLabelDe", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelDe() : null);
dto.put("filamentColorLabelFr", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelFr() : null);
dto.put("materialCode", item.getMaterialCode()); dto.put("materialCode", item.getMaterialCode());
dto.put("quality", item.getQuality()); dto.put("quality", item.getQuality());
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());

View File

@@ -54,7 +54,6 @@ public class QuoteStorageService {
return switch (ext) { return switch (ext) {
case "stl" -> "stl"; case "stl" -> "stl";
case "3mf" -> "3mf"; case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback; default -> fallback;
}; };
} }

View File

@@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService {
@Value("${app.mail.contact-request.customer.enabled:true}") @Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled; private boolean contactRequestCustomerMailEnabled;
@Value("${app.frontend.base-url:http://localhost:4200}")
private String frontendBaseUrl;
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
ContactRequestLocalizationService localizationService) { ContactRequestLocalizationService localizationService) {
this.emailNotificationService = emailNotificationService; this.emailNotificationService = emailNotificationService;
@@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail( emailNotificationService.sendEmail(
@@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
@@ -119,4 +124,8 @@ public class CustomQuoteRequestNotificationService {
} }
return value; return value;
} }
private String buildLogoUrl() {
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
} }

View File

@@ -0,0 +1,622 @@
package com.printcalculator.service.shop;
import com.printcalculator.dto.PublicMediaUsageDto;
import com.printcalculator.dto.ShopCategoryDetailDto;
import com.printcalculator.dto.ShopCategoryRefDto;
import com.printcalculator.dto.ShopCategoryTreeDto;
import com.printcalculator.dto.ShopProductCatalogResponseDto;
import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.dto.ShopProductModelDto;
import com.printcalculator.dto.ShopProductSummaryDto;
import com.printcalculator.dto.ShopProductVariantOptionDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.media.PublicMediaQueryService;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class PublicShopCatalogService {
private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY";
private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT";
private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository;
private final ShopProductVariantRepository shopProductVariantRepository;
private final ShopProductModelAssetRepository shopProductModelAssetRepository;
private final FilamentVariantRepository filamentVariantRepository;
private final PublicMediaQueryService publicMediaQueryService;
private final ShopStorageService shopStorageService;
public PublicShopCatalogService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository,
ShopProductVariantRepository shopProductVariantRepository,
ShopProductModelAssetRepository shopProductModelAssetRepository,
FilamentVariantRepository filamentVariantRepository,
PublicMediaQueryService publicMediaQueryService,
ShopStorageService shopStorageService) {
this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository;
this.shopProductVariantRepository = shopProductVariantRepository;
this.shopProductModelAssetRepository = shopProductModelAssetRepository;
this.filamentVariantRepository = filamentVariantRepository;
this.publicMediaQueryService = publicMediaQueryService;
this.shopStorageService = shopStorageService;
}
public List<ShopCategoryTreeDto> getCategories(String language) {
CategoryContext categoryContext = loadCategoryContext(language);
return buildCategoryTree(null, categoryContext, language);
}
public ShopCategoryDetailDto getCategory(String slug, String language) {
ShopCategory category = shopCategoryRepository.findBySlugAndIsActiveTrue(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"));
CategoryContext categoryContext = loadCategoryContext(language);
if (!categoryContext.categoriesById().containsKey(category.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found");
}
return buildCategoryDetail(category, categoryContext, language);
}
public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) {
CategoryContext categoryContext = loadCategoryContext(language);
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
ShopCategory selectedCategory = null;
if (categorySlug != null && !categorySlug.isBlank()) {
selectedCategory = categoryContext.categoriesBySlug().get(categorySlug.trim());
if (selectedCategory == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found");
}
}
Collection<UUID> allowedCategoryIds = selectedCategory == null
? categoryContext.categoriesById().keySet()
: resolveDescendantCategoryIds(selectedCategory.getId(), categoryContext.childrenByParentId());
List<ShopProductSummaryDto> products = productContext.entries().stream()
.filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId()))
.filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured()))
.map(entry -> toProductSummaryDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
))
.toList();
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
? buildCategoryDetail(selectedCategory, categoryContext, language)
: null;
return new ShopProductCatalogResponseDto(
selectedCategory != null ? selectedCategory.getSlug() : null,
Boolean.TRUE.equals(featuredOnly),
selectedCategoryDetail,
products
);
}
public ShopProductDetailDto getProduct(String slug, String language) {
CategoryContext categoryContext = loadCategoryContext(language);
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
ProductEntry entry = productContext.entriesBySlug().get(slug);
if (entry == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
ShopCategory category = entry.product().getCategory();
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
return toProductDetailDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
);
}
public ProductModelDownload getProductModelDownload(String slug) {
CategoryContext categoryContext = loadCategoryContext(null);
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
ProductEntry entry = productContext.entriesBySlug().get(slug);
if (entry == null || entry.modelAsset() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found");
}
Path path = shopStorageService.resolveStoredProductPath(
entry.modelAsset().getStoredRelativePath(),
entry.product().getId()
);
if (path == null || !Files.exists(path)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found");
}
return new ProductModelDownload(
path,
entry.modelAsset().getOriginalFilename(),
entry.modelAsset().getMimeType()
);
}
private CategoryContext loadCategoryContext(String language) {
List<ShopCategory> categories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
Map<UUID, ShopCategory> categoriesById = categories.stream()
.collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new));
Map<String, ShopCategory> categoriesBySlug = categories.stream()
.collect(Collectors.toMap(ShopCategory::getSlug, category -> category, (left, right) -> left, LinkedHashMap::new));
Map<UUID, List<ShopCategory>> childrenByParentId = buildChildrenByParentId(categories);
List<ProductEntry> publicProducts = loadPublicProducts(categoriesById.keySet());
Map<UUID, Integer> descendantProductCounts = resolveDescendantProductCounts(categories, childrenByParentId, publicProducts);
Map<String, List<PublicMediaUsageDto>> categoryMediaBySlug = publicMediaQueryService.getUsageMediaMap(
SHOP_CATEGORY_MEDIA_USAGE_TYPE,
categories.stream().map(this::categoryMediaUsageKey).toList(),
language
);
return new CategoryContext(
categoriesById,
categoriesBySlug,
childrenByParentId,
descendantProductCounts,
categoryMediaBySlug
);
}
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(),
language
);
Map<String, String> variantColorHexByMaterialAndColor = buildFilamentVariantColorHexMap();
Map<String, ProductEntry> entriesBySlug = entries.stream()
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
}
private Map<String, String> buildFilamentVariantColorHexMap() {
Map<String, String> colorsByMaterialAndColor = new LinkedHashMap<>();
for (FilamentVariant variant : filamentVariantRepository.findByIsActiveTrue()) {
String materialCode = variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: null;
String key = toMaterialAndColorKey(materialCode, variant.getColorName());
if (key == null) {
continue;
}
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
continue;
}
colorsByMaterialAndColor.putIfAbsent(key, colorHex);
}
return colorsByMaterialAndColor;
}
private List<ProductEntry> loadPublicProducts(Collection<UUID> activeCategoryIds) {
List<ShopProduct> products = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
if (products.isEmpty()) {
return List.of();
}
List<UUID> productIds = products.stream().map(ShopProduct::getId).toList();
Map<UUID, List<ShopProductVariant>> variantsByProductId = shopProductVariantRepository
.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(productIds)
.stream()
.collect(Collectors.groupingBy(
variant -> variant.getProduct().getId(),
LinkedHashMap::new,
Collectors.toList()
));
Map<UUID, ShopProductModelAsset> modelAssetByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds)
.stream()
.collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new));
return products.stream()
.filter(product -> product.getCategory() != null)
.filter(product -> activeCategoryIds.contains(product.getCategory().getId()))
.map(product -> {
List<ShopProductVariant> activeVariants = variantsByProductId.getOrDefault(product.getId(), List.of());
if (activeVariants.isEmpty()) {
return null;
}
ShopProductVariant defaultVariant = pickDefaultVariant(activeVariants);
return new ProductEntry(
product,
activeVariants,
defaultVariant,
modelAssetByProductId.get(product.getId())
);
})
.filter(Objects::nonNull)
.toList();
}
private Map<UUID, List<ShopCategory>> buildChildrenByParentId(List<ShopCategory> categories) {
Map<UUID, List<ShopCategory>> childrenByParentId = new LinkedHashMap<>();
for (ShopCategory category : categories) {
UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null;
childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category);
}
Comparator<ShopCategory> comparator = Comparator
.comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER));
childrenByParentId.values().forEach(children -> children.sort(comparator));
return childrenByParentId;
}
private Map<UUID, Integer> resolveDescendantProductCounts(List<ShopCategory> categories,
Map<UUID, List<ShopCategory>> childrenByParentId,
List<ProductEntry> publicProducts) {
Map<UUID, Integer> directProductCounts = new LinkedHashMap<>();
for (ProductEntry entry : publicProducts) {
UUID categoryId = entry.product().getCategory().getId();
directProductCounts.merge(categoryId, 1, Integer::sum);
}
Map<UUID, Integer> descendantCounts = new LinkedHashMap<>();
for (ShopCategory category : categories) {
resolveCategoryProductCount(category.getId(), childrenByParentId, directProductCounts, descendantCounts);
}
return descendantCounts;
}
private int resolveCategoryProductCount(UUID categoryId,
Map<UUID, List<ShopCategory>> childrenByParentId,
Map<UUID, Integer> directProductCounts,
Map<UUID, Integer> descendantCounts) {
Integer cached = descendantCounts.get(categoryId);
if (cached != null) {
return cached;
}
int total = directProductCounts.getOrDefault(categoryId, 0);
for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) {
total += resolveCategoryProductCount(child.getId(), childrenByParentId, directProductCounts, descendantCounts);
}
descendantCounts.put(categoryId, total);
return total;
}
private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId,
CategoryContext categoryContext,
String language) {
return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream()
.map(category -> new ShopCategoryTreeDto(
category.getId(),
category.getParentCategory() != null ? category.getParentCategory().getId() : null,
category.getSlug(),
category.getNameForLanguage(language),
category.getDescriptionForLanguage(language),
category.getSeoTitleForLanguage(language),
category.getSeoDescriptionForLanguage(language),
category.getOgTitle(),
category.getOgDescription(),
category.getIndexable(),
category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
buildCategoryTree(category.getId(), categoryContext, language)
))
.toList();
}
private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category,
CategoryContext categoryContext,
String language) {
List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of());
String localizedSeoTitle = category.getSeoTitleForLanguage(language);
String localizedSeoDescription = category.getSeoDescriptionForLanguage(language);
return new ShopCategoryDetailDto(
category.getId(),
category.getSlug(),
category.getNameForLanguage(language),
category.getDescriptionForLanguage(language),
localizedSeoTitle,
localizedSeoDescription,
category.getOgTitle(),
category.getOgDescription(),
category.getIndexable(),
category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
buildCategoryBreadcrumbs(category, language),
selectPrimaryMedia(images),
images,
buildCategoryTree(category.getId(), categoryContext, language)
);
}
private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category, String language) {
List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>();
ShopCategory current = category;
while (current != null) {
breadcrumbs.add(new ShopCategoryRefDto(
current.getId(),
current.getSlug(),
current.getNameForLanguage(language)
));
current = current.getParentCategory();
}
java.util.Collections.reverse(breadcrumbs);
return breadcrumbs;
}
private List<UUID> resolveDescendantCategoryIds(UUID rootId, Map<UUID, List<ShopCategory>> childrenByParentId) {
List<UUID> ids = new ArrayList<>();
collectDescendantCategoryIds(rootId, childrenByParentId, ids);
return ids;
}
private void collectDescendantCategoryIds(UUID categoryId,
Map<UUID, List<ShopCategory>> childrenByParentId,
List<UUID> accumulator) {
accumulator.add(categoryId);
for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) {
collectDescendantCategoryIds(child.getId(), childrenByParentId, accumulator);
}
}
private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto(
entry.product().getId(),
entry.product().getSlug(),
entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language),
entry.product().getIsFeatured(),
entry.product().getSortOrder(),
new ShopCategoryRefDto(
entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(),
entry.product().getCategory().getNameForLanguage(language)
),
resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images),
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
localizedPaths
);
}
private ShopProductDetailDto toProductDetailDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto(
entry.product().getId(),
entry.product().getSlug(),
entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language),
entry.product().getDescriptionForLanguage(language),
localizedSeoTitle,
localizedSeoDescription,
localizedSeoTitle,
localizedSeoDescription,
entry.product().getIndexable(),
entry.product().getIsFeatured(),
entry.product().getSortOrder(),
new ShopCategoryRefDto(
entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(),
entry.product().getCategory().getNameForLanguage(language)
),
buildCategoryBreadcrumbs(entry.product().getCategory(), language),
resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
entry.variants().stream()
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language))
.toList(),
selectPrimaryMedia(images),
images,
toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
localizedPaths
);
}
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
ShopProductVariant defaultVariant,
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
if (variant == null) {
return null;
}
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
String key = toMaterialAndColorKey(variant.getInternalMaterialCode(), variant.getColorName());
colorHex = key != null ? variantColorHexByMaterialAndColor.get(key) : null;
}
return new ShopProductVariantOptionDto(
variant.getId(),
variant.getSku(),
variant.getVariantLabel(),
variant.getColorName(),
variant.getColorLabelForLanguage(language),
colorHex,
variant.getPriceChf(),
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
);
}
private String toMaterialAndColorKey(String materialCode, String colorName) {
String normalizedMaterialCode = normalizeMaterialCode(materialCode);
String normalizedColorName = normalizeColorName(colorName);
if (normalizedMaterialCode == null || normalizedColorName == null) {
return null;
}
return normalizedMaterialCode + "|" + normalizedColorName;
}
private String normalizeMaterialCode(String materialCode) {
String raw = trimToNull(materialCode);
if (raw == null) {
return null;
}
return raw.toUpperCase(Locale.ROOT);
}
private String normalizeColorName(String colorName) {
String raw = trimToNull(colorName);
if (raw == null) {
return null;
}
return raw.toLowerCase(Locale.ROOT);
}
private String trimToNull(String value) {
String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) {
return null;
}
return raw;
}
private String normalizeLanguage(String language) {
String normalized = trimToNull(language);
if (normalized == null) {
return "it";
}
normalized = normalized.toLowerCase(Locale.ROOT);
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return switch (normalized) {
case "en", "de", "fr" -> normalized;
default -> "it";
};
}
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
if (entry.modelAsset() == null) {
return null;
}
return new ShopProductModelDto(
"/api/shop/products/" + entry.product().getSlug() + "/model",
entry.modelAsset().getOriginalFilename(),
entry.modelAsset().getMimeType(),
entry.modelAsset().getFileSizeBytes(),
entry.modelAsset().getBoundingBoxXMm(),
entry.modelAsset().getBoundingBoxYMm(),
entry.modelAsset().getBoundingBoxZMm()
);
}
private ShopProductVariant pickDefaultVariant(List<ShopProductVariant> variants) {
return variants.stream()
.filter(variant -> Boolean.TRUE.equals(variant.getIsDefault()))
.findFirst()
.orElseGet(() -> variants.isEmpty() ? null : variants.get(0));
}
private BigDecimal resolvePriceFrom(List<ShopProductVariant> variants) {
return variants.stream()
.map(ShopProductVariant::getPriceChf)
.filter(Objects::nonNull)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
private BigDecimal resolvePriceTo(List<ShopProductVariant> variants) {
return variants.stream()
.map(ShopProductVariant::getPriceChf)
.filter(Objects::nonNull)
.max(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
private PublicMediaUsageDto selectPrimaryMedia(List<PublicMediaUsageDto> images) {
if (images == null || images.isEmpty()) {
return null;
}
return images.stream()
.filter(image -> Boolean.TRUE.equals(image.getIsPrimary()))
.findFirst()
.orElse(images.get(0));
}
private String categoryMediaUsageKey(ShopCategory category) {
return category.getId().toString();
}
private String productMediaUsageKey(ShopProduct product) {
return product.getId().toString();
}
public record ProductModelDownload(Path path, String filename, String mimeType) {
}
private record CategoryContext(
Map<UUID, ShopCategory> categoriesById,
Map<String, ShopCategory> categoriesBySlug,
Map<UUID, List<ShopCategory>> childrenByParentId,
Map<UUID, Integer> descendantProductCounts,
Map<String, List<PublicMediaUsageDto>> categoryMediaBySlug
) {
}
private record PublicProductContext(
List<ProductEntry> entries,
Map<String, ProductEntry> entriesBySlug,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor
) {
}
private record ProductEntry(
ShopProduct product,
List<ShopProductVariant> variants,
ShopProductVariant defaultVariant,
ShopProductModelAsset modelAsset
) {
}
}

View File

@@ -0,0 +1,91 @@
package com.printcalculator.service.shop;
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 java.time.Duration;
import java.util.Optional;
import java.util.UUID;
@Service
public class ShopCartCookieService {
public static final String COOKIE_NAME = "shop_cart_session";
private static final String COOKIE_PATH = "/api/shop";
private final long cookieTtlDays;
private final boolean secureCookie;
private final String sameSite;
public ShopCartCookieService(
@Value("${shop.cart.cookie.ttl-days:30}") long cookieTtlDays,
@Value("${shop.cart.cookie.secure:false}") boolean secureCookie,
@Value("${shop.cart.cookie.same-site:Lax}") String sameSite
) {
this.cookieTtlDays = cookieTtlDays;
this.secureCookie = secureCookie;
this.sameSite = sameSite;
}
public Optional<UUID> extractSessionId(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())) {
continue;
}
try {
String value = cookie.getValue();
if (value == null || value.isBlank()) {
return Optional.empty();
}
return Optional.of(UUID.fromString(value.trim()));
} catch (IllegalArgumentException ignored) {
return Optional.empty();
}
}
return Optional.empty();
}
public boolean hasCartCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null || cookies.length == 0) {
return false;
}
for (Cookie cookie : cookies) {
if (COOKIE_NAME.equals(cookie.getName())) {
return true;
}
}
return false;
}
public ResponseCookie buildSessionCookie(UUID sessionId) {
return ResponseCookie.from(COOKIE_NAME, sessionId.toString())
.path(COOKIE_PATH)
.httpOnly(true)
.secure(secureCookie)
.sameSite(sameSite)
.maxAge(Duration.ofDays(Math.max(cookieTtlDays, 1)))
.build();
}
public ResponseCookie buildClearCookie() {
return ResponseCookie.from(COOKIE_NAME, "")
.path(COOKIE_PATH)
.httpOnly(true)
.secure(secureCookie)
.sameSite(sameSite)
.maxAge(Duration.ZERO)
.build();
}
public long getCookieTtlDays() {
return cookieTtlDays;
}
}

View File

@@ -0,0 +1,362 @@
package com.printcalculator.service.shop;
import com.printcalculator.dto.ShopCartAddItemRequest;
import com.printcalculator.dto.ShopCartUpdateItemRequest;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductVariantRepository;
import com.printcalculator.service.QuoteSessionTotalsService;
import com.printcalculator.service.quote.QuoteSessionResponseAssembler;
import com.printcalculator.service.quote.QuoteStorageService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
@Transactional(readOnly = true)
public class ShopCartService {
private static final String SHOP_CART_SESSION_TYPE = "SHOP_CART";
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
private static final String ACTIVE_STATUS = "ACTIVE";
private static final String EXPIRED_STATUS = "EXPIRED";
private static final String CONVERTED_STATUS = "CONVERTED";
private final QuoteSessionRepository quoteSessionRepository;
private final QuoteLineItemRepository quoteLineItemRepository;
private final ShopProductVariantRepository shopProductVariantRepository;
private final ShopProductModelAssetRepository shopProductModelAssetRepository;
private final QuoteSessionTotalsService quoteSessionTotalsService;
private final QuoteSessionResponseAssembler quoteSessionResponseAssembler;
private final QuoteStorageService quoteStorageService;
private final ShopStorageService shopStorageService;
private final ShopCartCookieService shopCartCookieService;
public ShopCartService(
QuoteSessionRepository quoteSessionRepository,
QuoteLineItemRepository quoteLineItemRepository,
ShopProductVariantRepository shopProductVariantRepository,
ShopProductModelAssetRepository shopProductModelAssetRepository,
QuoteSessionTotalsService quoteSessionTotalsService,
QuoteSessionResponseAssembler quoteSessionResponseAssembler,
QuoteStorageService quoteStorageService,
ShopStorageService shopStorageService,
ShopCartCookieService shopCartCookieService
) {
this.quoteSessionRepository = quoteSessionRepository;
this.quoteLineItemRepository = quoteLineItemRepository;
this.shopProductVariantRepository = shopProductVariantRepository;
this.shopProductModelAssetRepository = shopProductModelAssetRepository;
this.quoteSessionTotalsService = quoteSessionTotalsService;
this.quoteSessionResponseAssembler = quoteSessionResponseAssembler;
this.quoteStorageService = quoteStorageService;
this.shopStorageService = shopStorageService;
this.shopCartCookieService = shopCartCookieService;
}
public CartResult loadCart(HttpServletRequest request) {
boolean hadCookie = shopCartCookieService.hasCartCookie(request);
Optional<QuoteSession> session = resolveValidCartSession(request);
if (session.isEmpty()) {
return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie);
}
QuoteSession validSession = session.get();
touchSession(validSession);
return CartResult.withSession(buildCartResponse(validSession), validSession.getId(), false);
}
@Transactional
public CartResult addItem(HttpServletRequest request, ShopCartAddItemRequest payload) {
int quantityToAdd = normalizeQuantity(payload != null ? payload.getQuantity() : null);
ShopProductVariant variant = getPurchasableVariant(payload != null ? payload.getShopProductVariantId() : null);
QuoteSession session = resolveValidCartSession(request).orElseGet(this::createCartSession);
touchSession(session);
QuoteLineItem lineItem = quoteLineItemRepository
.findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
session.getId(),
SHOP_LINE_ITEM_TYPE,
variant.getId()
)
.orElseGet(() -> buildShopLineItem(session, variant));
int existingQuantity = lineItem.getQuantity() != null && lineItem.getQuantity() > 0
? lineItem.getQuantity()
: 0;
int newQuantity = existingQuantity + quantityToAdd;
lineItem.setQuantity(newQuantity);
refreshLineItemSnapshot(lineItem, variant);
lineItem.setUpdatedAt(OffsetDateTime.now());
quoteLineItemRepository.save(lineItem);
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
}
@Transactional
public CartResult updateItem(HttpServletRequest request, UUID lineItemId, ShopCartUpdateItemRequest payload) {
QuoteSession session = resolveValidCartSession(request)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found"));
QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found"));
if (!SHOP_LINE_ITEM_TYPE.equals(item.getLineItemType())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid cart item type");
}
item.setQuantity(normalizeQuantity(payload != null ? payload.getQuantity() : null));
item.setUpdatedAt(OffsetDateTime.now());
if (item.getShopProductVariant() != null) {
refreshLineItemSnapshot(item, item.getShopProductVariant());
}
quoteLineItemRepository.save(item);
touchSession(session);
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
}
@Transactional
public CartResult removeItem(HttpServletRequest request, UUID lineItemId) {
QuoteSession session = resolveValidCartSession(request)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found"));
QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found"));
quoteLineItemRepository.delete(item);
touchSession(session);
return CartResult.withSession(buildCartResponse(session), session.getId(), false);
}
@Transactional
public CartResult clearCart(HttpServletRequest request) {
boolean hadCookie = shopCartCookieService.hasCartCookie(request);
Optional<QuoteSession> session = resolveValidCartSession(request);
if (session.isPresent()) {
QuoteSession current = session.get();
quoteSessionRepository.delete(current);
}
return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie);
}
private Optional<QuoteSession> resolveValidCartSession(HttpServletRequest request) {
Optional<UUID> sessionId = shopCartCookieService.extractSessionId(request);
if (sessionId.isEmpty()) {
return Optional.empty();
}
Optional<QuoteSession> session = quoteSessionRepository.findByIdAndSessionType(sessionId.get(), SHOP_CART_SESSION_TYPE);
if (session.isEmpty()) {
return Optional.empty();
}
QuoteSession quoteSession = session.get();
if (isSessionUnavailable(quoteSession)) {
if (!EXPIRED_STATUS.equals(quoteSession.getStatus()) && !CONVERTED_STATUS.equals(quoteSession.getStatus())) {
quoteSession.setStatus(EXPIRED_STATUS);
quoteSessionRepository.save(quoteSession);
}
return Optional.empty();
}
return Optional.of(quoteSession);
}
private QuoteSession createCartSession() {
QuoteSession session = new QuoteSession();
session.setStatus(ACTIVE_STATUS);
session.setSessionType(SHOP_CART_SESSION_TYPE);
session.setPricingVersion("v1");
session.setMaterialCode("SHOP");
session.setSupportsEnabled(false);
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(nowPlusCookieTtl());
session.setSetupCostChf(BigDecimal.ZERO);
return quoteSessionRepository.save(session);
}
private Map<String, Object> buildCartResponse(QuoteSession session) {
List<QuoteLineItem> items = quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(session.getId());
QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items);
return quoteSessionResponseAssembler.assemble(session, items, totals);
}
private QuoteLineItem buildShopLineItem(QuoteSession session, ShopProductVariant variant) {
ShopProduct product = variant.getProduct();
ShopProductModelAsset modelAsset = product != null ? shopProductModelAssetRepository.findByProduct_Id(product.getId()).orElse(null) : null;
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setStatus("READY");
item.setLineItemType(SHOP_LINE_ITEM_TYPE);
item.setQuantity(0);
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
item.setSupportsEnabled(false);
item.setInfillPercent(0);
item.setPricingBreakdown(new HashMap<>());
refreshLineItemSnapshot(item, variant);
applyModelAssetSnapshot(item, session, modelAsset);
return item;
}
private void refreshLineItemSnapshot(QuoteLineItem item, ShopProductVariant variant) {
ShopProduct product = variant.getProduct();
ShopCategory category = product != null ? product.getCategory() : null;
item.setShopProduct(product);
item.setShopProductVariant(variant);
item.setShopProductSlug(product != null ? product.getSlug() : null);
item.setShopProductName(product != null ? product.getName() : null);
item.setShopVariantLabel(variant.getVariantLabel());
item.setShopVariantColorName(variant.getColorName());
item.setShopVariantColorHex(variant.getColorHex());
item.setDisplayName(product != null ? product.getName() : item.getDisplayName());
item.setColorCode(variant.getColorName());
item.setMaterialCode(variant.getInternalMaterialCode());
item.setQuality(null);
item.setUnitPriceChf(variant.getPriceChf() != null ? variant.getPriceChf() : BigDecimal.ZERO);
Map<String, Object> breakdown = item.getPricingBreakdown() != null
? new HashMap<>(item.getPricingBreakdown())
: new HashMap<>();
breakdown.put("type", SHOP_LINE_ITEM_TYPE);
breakdown.put("unitPriceChf", item.getUnitPriceChf());
item.setPricingBreakdown(breakdown);
}
private void applyModelAssetSnapshot(QuoteLineItem item, QuoteSession session, ShopProductModelAsset modelAsset) {
if (modelAsset == null) {
if (item.getOriginalFilename() == null || item.getOriginalFilename().isBlank()) {
item.setOriginalFilename(item.getShopProductSlug() != null ? item.getShopProductSlug() : "shop-product");
}
item.setBoundingBoxXMm(BigDecimal.ZERO);
item.setBoundingBoxYMm(BigDecimal.ZERO);
item.setBoundingBoxZMm(BigDecimal.ZERO);
item.setStoredPath(null);
return;
}
item.setOriginalFilename(modelAsset.getOriginalFilename());
item.setBoundingBoxXMm(modelAsset.getBoundingBoxXMm() != null ? modelAsset.getBoundingBoxXMm() : BigDecimal.ZERO);
item.setBoundingBoxYMm(modelAsset.getBoundingBoxYMm() != null ? modelAsset.getBoundingBoxYMm() : BigDecimal.ZERO);
item.setBoundingBoxZMm(modelAsset.getBoundingBoxZMm() != null ? modelAsset.getBoundingBoxZMm() : BigDecimal.ZERO);
String copiedStoredPath = copyModelAssetIntoSession(session, modelAsset);
item.setStoredPath(copiedStoredPath);
}
private String copyModelAssetIntoSession(QuoteSession session, ShopProductModelAsset modelAsset) {
if (session == null || modelAsset == null || modelAsset.getProduct() == null) {
return null;
}
Path source = shopStorageService.resolveStoredProductPath(
modelAsset.getStoredRelativePath(),
modelAsset.getProduct().getId()
);
if (source == null || !Files.exists(source)) {
return null;
}
try {
Path sessionDir = quoteStorageService.sessionStorageDir(session.getId());
String extension = quoteStorageService.getSafeExtension(modelAsset.getOriginalFilename(), "stl");
Path destination = quoteStorageService.resolveSessionPath(
sessionDir,
UUID.randomUUID() + "." + extension
);
Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING);
return quoteStorageService.toStoredPath(destination);
} catch (IOException e) {
return null;
}
}
private ShopProductVariant getPurchasableVariant(UUID variantId) {
if (variantId == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "shopProductVariantId is required");
}
ShopProductVariant variant = shopProductVariantRepository.findById(variantId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not found"));
ShopProduct product = variant.getProduct();
ShopCategory category = product != null ? product.getCategory() : null;
if (product == null
|| category == null
|| !Boolean.TRUE.equals(variant.getIsActive())
|| !Boolean.TRUE.equals(product.getIsActive())
|| !Boolean.TRUE.equals(category.getIsActive())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not available");
}
return variant;
}
private void touchSession(QuoteSession session) {
session.setStatus(ACTIVE_STATUS);
session.setExpiresAt(nowPlusCookieTtl());
quoteSessionRepository.save(session);
}
private OffsetDateTime nowPlusCookieTtl() {
return OffsetDateTime.now().plusDays(Math.max(shopCartCookieService.getCookieTtlDays(), 1));
}
private boolean isSessionUnavailable(QuoteSession session) {
if (session == null) {
return true;
}
if (!SHOP_CART_SESSION_TYPE.equalsIgnoreCase(session.getSessionType())) {
return true;
}
if (!ACTIVE_STATUS.equalsIgnoreCase(session.getStatus())) {
return true;
}
if (CONVERTED_STATUS.equalsIgnoreCase(session.getStatus())) {
return true;
}
OffsetDateTime expiresAt = session.getExpiresAt();
return expiresAt != null && expiresAt.isBefore(OffsetDateTime.now());
}
private int normalizeQuantity(Integer quantity) {
if (quantity == null || quantity < 1) {
return 1;
}
return quantity;
}
public record CartResult(Map<String, Object> response, UUID sessionId, boolean clearCookie) {
public static CartResult withSession(Map<String, Object> response, UUID sessionId, boolean clearCookie) {
return new CartResult(response, sessionId, clearCookie);
}
public static CartResult empty(Map<String, Object> response, boolean clearCookie) {
return new CartResult(response, null, clearCookie);
}
}
}

View File

@@ -0,0 +1,66 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopProduct;
import java.text.Normalizer;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
final class ShopPublicPathSupport {
private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/";
private ShopPublicPathSupport() {
}
static String buildProductPathSegment(ShopProduct product, String language) {
String localizedName = product.getNameForLanguage(language);
String idPrefix = productIdPrefix(product.getId());
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
}
static Map<String, String> buildLocalizedProductPaths(ShopProduct product) {
Map<String, String> localizedPaths = new LinkedHashMap<>();
for (String language : ShopProduct.SUPPORTED_LANGUAGES) {
localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language));
}
return localizedPaths;
}
static String productIdPrefix(UUID productId) {
if (productId == null) {
return "";
}
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
int dashIndex = raw.indexOf('-');
if (dashIndex > 0) {
return raw.substring(0, dashIndex);
}
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
}
static String slugify(String rawValue) {
String safeValue = rawValue == null ? "" : rawValue;
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "")
.replaceAll("-{2,}", "-");
return normalized;
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -0,0 +1,220 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct;
import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
public class ShopSitemapService {
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
private static final String DEFAULT_LANGUAGE = "it";
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final Map<String, String> HREFLANG_BY_LANGUAGE = Map.of(
"it", "it-CH",
"en", "en-CH",
"de", "de-CH",
"fr", "fr-CH"
);
private final ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository;
private final String frontendBaseUrl;
private final Duration cacheTtl;
private final Clock clock;
private volatile CachedSitemap cachedSitemap;
@Autowired
public ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository,
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
@Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds) {
this(shopCategoryRepository, shopProductRepository, frontendBaseUrl, cacheSeconds, Clock.systemUTC());
}
ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
ShopProductRepository shopProductRepository,
String frontendBaseUrl,
long cacheSeconds,
Clock clock) {
this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository;
this.frontendBaseUrl = normalizeBaseUrl(frontendBaseUrl);
this.cacheTtl = cacheSeconds > 0 ? Duration.ofSeconds(cacheSeconds) : Duration.ZERO;
this.clock = clock;
}
public String getShopSitemapXml() {
Instant now = Instant.now(clock);
CachedSitemap current = cachedSitemap;
if (current != null && now.isBefore(current.expiresAt())) {
return current.xml();
}
synchronized (this) {
current = cachedSitemap;
now = Instant.now(clock);
if (current != null && now.isBefore(current.expiresAt())) {
return current.xml();
}
String xml = buildSitemapXml();
Instant expiresAt = cacheTtl.isZero() ? now : now.plus(cacheTtl);
cachedSitemap = new CachedSitemap(xml, expiresAt);
return xml;
}
}
private String buildSitemapXml() {
List<ShopCategory> activeCategories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
Set<UUID> activeCategoryIds = activeCategories.stream()
.map(ShopCategory::getId)
.collect(Collectors.toSet());
List<ShopProduct> activeProducts = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
StringBuilder xml = new StringBuilder(16_384);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ");
xml.append("xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n");
appendCategoryUrls(xml, activeCategories);
appendProductUrls(xml, activeProducts, activeCategoryIds);
xml.append("</urlset>\n");
return xml.toString();
}
private void appendCategoryUrls(StringBuilder xml, List<ShopCategory> categories) {
for (ShopCategory category : categories) {
if (!Boolean.TRUE.equals(category.getIndexable())) {
continue;
}
String encodedSlug = pathEncodeSegment(category.getSlug());
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/" + encodedSlug);
}
appendUrlEntry(xml, hrefByLanguage, category.getUpdatedAt());
}
}
private void appendProductUrls(StringBuilder xml,
List<ShopProduct> products,
Set<UUID> activeCategoryIds) {
for (ShopProduct product : products) {
if (!Boolean.TRUE.equals(product.getIndexable())) {
continue;
}
if (product.getCategory() == null || !activeCategoryIds.contains(product.getCategory().getId())) {
continue;
}
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
String publicSegment = ShopPublicPathSupport.buildProductPathSegment(product, language);
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
}
appendUrlEntry(xml, hrefByLanguage, product.getUpdatedAt());
}
}
private void appendUrlEntry(StringBuilder xml,
Map<String, String> hrefByLanguage,
OffsetDateTime lastmod) {
String defaultHref = hrefByLanguage.get(DEFAULT_LANGUAGE);
if (defaultHref == null || defaultHref.isBlank()) {
return;
}
for (String locLanguage : SUPPORTED_LANGUAGES) {
String locHref = hrefByLanguage.get(locLanguage);
if (locHref == null || locHref.isBlank()) {
continue;
}
appendLocalizedUrlEntry(xml, locHref, hrefByLanguage, defaultHref, lastmod);
}
}
private void appendLocalizedUrlEntry(StringBuilder xml,
String locHref,
Map<String, String> hrefByLanguage,
String defaultHref,
OffsetDateTime lastmod) {
xml.append(" <url>\n");
xml.append(" <loc>").append(xmlEscape(locHref)).append("</loc>\n");
for (String language : SUPPORTED_LANGUAGES) {
String href = hrefByLanguage.get(language);
if (href == null || href.isBlank()) {
continue;
}
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
.append("\" href=\"")
.append(xmlEscape(href))
.append("\" />\n");
}
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"")
.append(xmlEscape(defaultHref))
.append("\" />\n");
if (lastmod != null) {
xml.append(" <lastmod>").append(LASTMOD_FORMATTER.format(lastmod)).append("</lastmod>\n");
}
xml.append(" </url>\n");
}
private String pathEncodeSegment(String rawSegment) {
String safeSegment = rawSegment == null ? "" : rawSegment;
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
}
private String xmlEscape(String value) {
return String.valueOf(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
private String normalizeBaseUrl(String baseUrl) {
String normalized = (baseUrl == null ? "" : baseUrl).trim();
if (normalized.isBlank()) {
return "http://localhost:4200";
}
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private record CachedSitemap(String xml, Instant expiresAt) {
}
}

View File

@@ -0,0 +1,50 @@
package com.printcalculator.service.shop;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
@Service
public class ShopStorageService {
private final Path storageRoot;
public ShopStorageService(@Value("${shop.storage.root:storage_shop}") String storageRoot) {
this.storageRoot = Paths.get(storageRoot).toAbsolutePath().normalize();
}
public Path productModelStorageDir(UUID productId) throws IOException {
Path dir = storageRoot.resolve(Path.of("products", productId.toString(), "3d-models")).normalize();
if (!dir.startsWith(storageRoot)) {
throw new IOException("Invalid shop product storage path");
}
Files.createDirectories(dir);
return dir;
}
public Path resolveStoredProductPath(String storedRelativePath, UUID expectedProductId) {
if (storedRelativePath == null || storedRelativePath.isBlank()) {
return null;
}
try {
Path raw = Path.of(storedRelativePath).normalize();
Path resolved = raw.isAbsolute() ? raw : storageRoot.resolve(raw).normalize();
Path expectedPrefix = storageRoot.resolve(Path.of("products", expectedProductId.toString())).normalize();
if (!resolved.startsWith(expectedPrefix)) {
return null;
}
return resolved;
} catch (InvalidPathException e) {
return null;
}
}
public String toStoredPath(Path absolutePath) {
return storageRoot.relativize(absolutePath.toAbsolutePath().normalize()).toString();
}
}

View File

@@ -3,6 +3,10 @@ app.mail.admin.enabled=false
app.mail.contact-request.admin.enabled=false app.mail.contact-request.admin.enabled=false
# Admin back-office local test credentials # Admin back-office local test credentials
admin.password=local-admin-password admin.password=ciaociao
admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000 admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
admin.session.ttl-minutes=480 admin.session.ttl-minutes=480
# Local media storage served by a local static server on port 8081.
media.storage.root=/Users/joe/IdeaProjects/print-calculator/storage_media
media.ffmpeg.path=ffmpeg

View File

@@ -26,6 +26,16 @@ clamav.host=${CLAMAV_HOST:clamav}
clamav.port=${CLAMAV_PORT:3310} clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} clamav.enabled=${CLAMAV_ENABLED:false}
# Media configuration
media.storage.root=${MEDIA_STORAGE_ROOT:storage_media}
media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg}
media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400}
shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600}
shop.storage.root=${SHOP_STORAGE_ROOT:storage_shop}
shop.cart.cookie.ttl-days=${SHOP_CART_COOKIE_TTL_DAYS:30}
shop.cart.cookie.secure=${SHOP_CART_COOKIE_SECURE:false}
shop.cart.cookie.same-site=${SHOP_CART_COOKIE_SAME_SITE:Lax}
# TWINT Configuration # TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
@@ -46,6 +56,13 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch} app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
openai.translation.api-key=${OPENAI_API_KEY:}
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
# Admin back-office authentication # Admin back-office authentication
admin.password=${ADMIN_PASSWORD} admin.password=${ADMIN_PASSWORD}

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