From 789d224ea0669bc8267721972636bd2f170a5562 Mon Sep 17 00:00:00 2001 From: "S. Esmaeili" Date: Wed, 21 Oct 2026 20:31:05 +0330 Subject: [PATCH] feat: monorepo with NestJS API, admin panel and DB-driven website Restructure the repo into a pnpm + Turborepo monorepo: - apps/web: Nuxt 4 site, now server-rendered and reading all content from the API; pages cached with SWR and purged on content changes - apps/api: NestJS 12 + Prisma 7 + PostgreSQL. Auth with short-lived access tokens and rotating httpOnly refresh cookies, roles (ADMIN, EDITOR), catalog, editable site sections, media upload to S3-compatible storage (converted to WebP), contact messages, audit log - apps/admin: Vue 3 admin panel (Ant Design Vue, Pinia, Tailwind) for products, categories, site sections, messages, media and users - packages/shared: Zod schemas and types shared by API and admin Also: - seed script that loads the original 8 categories / 89 products and section content - Docker images for api (with a one-off migrate service), web, admin, and an nginx gateway for both domains - dev and production compose files, daily database backup service - API e2e tests Removes the committed static out/ build and the old root Dockerfile. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 21 +- .env.example | 34 + .gitignore | 15 +- .prettierignore | 7 + .prettierrc | 3 + Dockerfile | 10 - README.md | 297 +- app/composables/useSiteSeo.ts | 28 - app/stores/contact.ts | 24 - apps/admin/index.html | 14 + apps/admin/package.json | 33 + {out => apps/admin/public}/favicon.svg | 0 .../public}/fonts/IRANSansWebFaNum.woff2 | Bin .../public}/fonts/IRANSansWebFaNum_Bold.woff2 | Bin .../fonts/IRANSansWebFaNum_Medium.woff2 | Bin apps/admin/src/App.vue | 21 + apps/admin/src/api/http.ts | 59 + apps/admin/src/api/types.ts | 65 + apps/admin/src/components/FormField.vue | 21 + apps/admin/src/components/IconSelect.vue | 35 + apps/admin/src/components/ImageField.vue | 34 + apps/admin/src/components/ImageRefField.vue | 21 + apps/admin/src/components/ListEditor.vue | 63 + apps/admin/src/components/MediaPicker.vue | 115 + apps/admin/src/components/PageHeader.vue | 18 + .../src/components/SectionHeadFields.vue | 29 + apps/admin/src/components/icons.ts | 104 + apps/admin/src/composables/useZodForm.ts | 47 + apps/admin/src/layouts/AdminLayout.vue | 132 + apps/admin/src/main.ts | 12 + apps/admin/src/pages/CategoriesPage.vue | 132 + apps/admin/src/pages/CategoryEditPage.vue | 203 + apps/admin/src/pages/DashboardPage.vue | 87 + apps/admin/src/pages/LoginPage.vue | 55 + apps/admin/src/pages/MediaPage.vue | 151 + apps/admin/src/pages/MessagesPage.vue | 126 + apps/admin/src/pages/ProductEditPage.vue | 245 + apps/admin/src/pages/ProductsPage.vue | 266 + apps/admin/src/pages/ProfilePage.vue | 56 + apps/admin/src/pages/SectionEditPage.vue | 105 + apps/admin/src/pages/SectionsPage.vue | 24 + apps/admin/src/pages/UsersPage.vue | 174 + apps/admin/src/router.ts | 57 + apps/admin/src/sections/AboutForm.vue | 54 + apps/admin/src/sections/ContactForm.vue | 34 + apps/admin/src/sections/FooterForm.vue | 20 + apps/admin/src/sections/HeroForm.vue | 78 + apps/admin/src/sections/IndustriesForm.vue | 39 + apps/admin/src/sections/ProductsIntroForm.vue | 32 + apps/admin/src/sections/ServicesForm.vue | 36 + apps/admin/src/sections/SettingsForm.vue | 132 + apps/admin/src/sections/StandardsForm.vue | 34 + apps/admin/src/sections/StatsForm.vue | 30 + apps/admin/src/sections/TapeForm.vue | 14 + apps/admin/src/sections/TestimonialsForm.vue | 29 + apps/admin/src/stores/auth.ts | 67 + apps/admin/src/style.css | 41 + apps/admin/src/utils/format.ts | 44 + apps/admin/src/utils/slug.ts | 7 + apps/admin/tsconfig.app.json | 13 + apps/admin/tsconfig.json | 4 + apps/admin/tsconfig.node.json | 14 + apps/admin/vite.config.ts | 23 + apps/api/.env.example | 38 + apps/api/nest-cli.json | 8 + apps/api/package.json | 53 + apps/api/prisma.config.ts | 17 + .../20261021130147_init/migration.sql | 212 + .../api/prisma/migrations/migration_lock.toml | 3 + apps/api/prisma/schema.prisma | 173 + .../images => apps/api/seed-assets}/about.jpg | Bin {out/images => apps/api/seed-assets}/hero.jpg | Bin .../seed-assets}/industries/construction.jpg | Bin .../api/seed-assets}/industries/mining.jpg | Bin .../api/seed-assets}/industries/oil-gas.jpg | Bin .../seed-assets}/industries/petrochemical.jpg | Bin .../seed-assets}/industries/power-energy.jpg | Bin .../api/seed-assets}/industries/transport.jpg | Bin .../api/seed-assets}/products/eyewear/1.webp | Bin .../api/seed-assets}/products/eyewear/10.webp | Bin .../api/seed-assets}/products/eyewear/11.webp | Bin .../api/seed-assets}/products/eyewear/2.webp | Bin .../api/seed-assets}/products/eyewear/3.webp | Bin .../api/seed-assets}/products/eyewear/4.webp | Bin .../api/seed-assets}/products/eyewear/5.webp | Bin .../api/seed-assets}/products/eyewear/6.webp | Bin .../api/seed-assets}/products/eyewear/7.webp | Bin .../api/seed-assets}/products/eyewear/8.webp | Bin .../api/seed-assets}/products/eyewear/9.webp | Bin .../api/seed-assets}/products/gloves.jpg | Bin .../seed-assets}/products/gloves/acid/1.webp | Bin .../seed-assets}/products/gloves/acid/2.webp | Bin .../seed-assets}/products/gloves/acid/3.webp | Bin .../seed-assets}/products/gloves/acid/4.webp | Bin .../seed-assets}/products/gloves/cut/1.webp | Bin .../seed-assets}/products/gloves/cut/2.webp | Bin .../seed-assets}/products/gloves/cut/3.webp | Bin .../seed-assets}/products/gloves/cut/4.webp | Bin .../seed-assets}/products/gloves/cut/5.webp | Bin .../products/gloves/leather/1.webp | Bin .../products/gloves/leather/2.webp | Bin .../products/gloves/leather/3.webp | Bin .../products/gloves/leather/4.webp | Bin .../products/gloves/leather/5.webp | Bin .../products/gloves/leather/6.webp | Bin .../products/gloves/solvent/1.webp | Bin .../products/gloves/solvent/2.webp | Bin .../products/gloves/solvent/3.webp | Bin .../products/gloves/solvent/4.webp | Bin .../api/seed-assets}/products/goggles.jpg | Bin .../api/seed-assets}/products/harness.jpg | Bin .../api/seed-assets}/products/hearing.jpg | Bin .../api/seed-assets}/products/hearing/1.webp | Bin .../api/seed-assets}/products/hearing/2.webp | Bin .../api/seed-assets}/products/hearing/3.webp | Bin .../api/seed-assets}/products/hearing/4.webp | Bin .../api/seed-assets}/products/hearing/5.webp | Bin .../api/seed-assets}/products/hearing/6.webp | Bin .../api/seed-assets}/products/hearing/7.webp | Bin .../api/seed-assets}/products/hearing/8.webp | Bin .../api/seed-assets}/products/helmet.jpg | Bin .../api/seed-assets}/products/helmets/1.webp | Bin .../api/seed-assets}/products/helmets/10.webp | Bin .../api/seed-assets}/products/helmets/11.webp | Bin .../api/seed-assets}/products/helmets/12.webp | Bin .../api/seed-assets}/products/helmets/2.webp | Bin .../api/seed-assets}/products/helmets/3.webp | Bin .../api/seed-assets}/products/helmets/4.webp | Bin .../api/seed-assets}/products/helmets/5.webp | Bin .../api/seed-assets}/products/helmets/9.webp | Bin .../api/seed-assets}/products/respiratory.jpg | Bin .../seed-assets}/products/respiratory/1.webp | Bin .../seed-assets}/products/respiratory/10.webp | Bin .../seed-assets}/products/respiratory/11.webp | Bin .../seed-assets}/products/respiratory/2.webp | Bin .../seed-assets}/products/respiratory/3.webp | Bin .../seed-assets}/products/respiratory/4.webp | Bin .../seed-assets}/products/respiratory/5.webp | Bin .../seed-assets}/products/respiratory/6.webp | Bin .../seed-assets}/products/respiratory/7.webp | Bin .../seed-assets}/products/respiratory/8.webp | Bin .../seed-assets}/products/respiratory/9.webp | Bin .../api/seed-assets}/products/shoes.jpg | Bin .../seed-assets}/products/shoes/office/1.webp | Bin .../seed-assets}/products/shoes/office/2.webp | Bin .../seed-assets}/products/shoes/office/3.webp | Bin .../seed-assets}/products/shoes/office/4.webp | Bin .../seed-assets}/products/shoes/office/5.webp | Bin .../seed-assets}/products/shoes/office/6.webp | Bin .../seed-assets}/products/shoes/office/7.webp | Bin .../seed-assets}/products/shoes/office/8.webp | Bin .../seed-assets}/products/shoes/worker/1.png | Bin .../seed-assets}/products/shoes/worker/2.png | Bin .../seed-assets}/products/shoes/worker/3.png | Bin .../seed-assets}/products/shoes/worker/4.png | Bin .../seed-assets}/products/shoes/worker/5.png | Bin .../seed-assets}/products/shoes/worker/6.png | Bin .../api/seed-assets}/products/traffic.jpg | Bin .../api/seed-assets}/products/vest.jpg | Bin apps/api/src/app.module.ts | 48 + apps/api/src/auth/auth.controller.ts | 101 + apps/api/src/auth/auth.service.ts | 139 + apps/api/src/catalog/catalog.module.ts | 139 + apps/api/src/catalog/categories.service.ts | 132 + apps/api/src/catalog/products.service.ts | 165 + apps/api/src/common/auth.ts | 80 + apps/api/src/common/common.module.ts | 60 + .../api/src/common/prisma-exception.filter.ts | 59 + apps/api/src/common/zod.pipe.ts | 26 + apps/api/src/config/env.ts | 53 + apps/api/src/contact/contact.module.ts | 137 + apps/api/src/dashboard/dashboard.module.ts | 49 + apps/api/src/main.ts | 26 + apps/api/src/media/media.controller.ts | 69 + apps/api/src/media/media.service.ts | 139 + apps/api/src/media/s3-storage.ts | 88 + apps/api/src/prisma/prisma.service.ts | 22 + apps/api/src/public/public.module.ts | 122 + apps/api/src/sections/sections.module.ts | 101 + .../api/src/seed}/data/categories.ts | 10 +- apps/api/src/seed/data/sections.ts | 279 + apps/api/src/seed/seed.ts | 176 + apps/api/src/users/users.controller.ts | 118 + apps/api/test/api.e2e-spec.ts | 158 + apps/api/tsconfig.build.json | 9 + apps/api/tsconfig.json | 24 + apps/api/vitest.config.e2e.ts | 13 + apps/web/.env.example | 3 + {app => apps/web/app}/app.vue | 6 +- .../web/app}/assets/css/antd-overrides.css | 0 {app => apps/web/app}/assets/css/main.css | 0 .../web/app}/components/layout/AppShell.vue | 0 .../web/app}/components/layout/BackToTop.vue | 0 .../web/app}/components/layout/Cursor.vue | 0 .../web/app}/components/layout/Footer.vue | 89 +- .../web/app}/components/layout/Header.vue | 3 +- .../web/app}/components/layout/Preloader.vue | 0 .../web/app}/components/sections/About.vue | 46 +- .../web/app}/components/sections/Contact.vue | 73 +- .../web/app}/components/sections/CountUp.vue | 0 .../web/app}/components/sections/Hero.vue | 57 +- .../app}/components/sections/Industries.vue | 52 +- .../web/app}/components/sections/Products.vue | 69 +- .../web/app}/components/sections/Services.vue | 67 +- .../app}/components/sections/Standards.vue | 30 +- .../web/app}/components/sections/Stats.vue | 9 +- .../app}/components/sections/TapeMarquee.vue | 13 +- .../app}/components/sections/Testimonials.vue | 32 +- apps/web/app/components/ui/DynamicIcon.vue | 112 + .../app}/components/ui/ImagePlaceholder.vue | 0 apps/web/app/components/ui/Lines.vue | 9 + {app => apps/web/app}/components/ui/Logo.vue | 0 .../web/app}/components/ui/Reveal.vue | 0 .../web/app}/components/ui/SectionHead.vue | 13 +- .../web/app}/composables/useJsonLd.ts | 0 .../web/app}/composables/useScrollY.ts | 0 apps/web/app/composables/useSeoContext.ts | 13 + apps/web/app/composables/useSiteSeo.ts | 46 + .../web/app}/composables/useTehranClock.ts | 0 {app => apps/web/app}/design/theme.ts | 12 +- {app => apps/web/app}/error.vue | 0 .../web/app}/pages/categories/[slug].vue | 56 +- {app => apps/web/app}/pages/index.vue | 6 +- {app => apps/web/app}/plugins/antd-style.ts | 0 apps/web/app/plugins/api.ts | 12 + apps/web/app/stores/contact.ts | 41 + apps/web/app/stores/site.ts | 19 + {app => apps/web/app}/stores/ui.ts | 0 {app => apps/web/app}/utils/motion.ts | 0 nuxt.config.ts => apps/web/nuxt.config.ts | 61 +- apps/web/package.json | 33 + {public => apps/web/public}/favicon.svg | 0 .../web/public}/fonts/IRANSansWebFaNum.woff2 | Bin .../fonts/IRANSansWebFaNum_Black.woff2 | Bin .../public}/fonts/IRANSansWebFaNum_Bold.woff2 | Bin .../fonts/IRANSansWebFaNum_Light.woff2 | Bin .../fonts/IRANSansWebFaNum_Medium.woff2 | Bin .../fonts/IRANSansWebFaNum_UltraLight.woff2 | Bin {out => apps/web/public}/images/logo.png | Bin .../routes/_internal/revalidate.post.ts | 25 + .../server}/routes/manifest.webmanifest.ts | 10 +- .../web/server}/routes/robots.txt.ts | 8 +- .../web/server}/routes/sitemap.xml.ts | 16 +- apps/web/server/utils/api.ts | 11 + apps/web/shared/config/site.ts | 15 + apps/web/shared/seo/schema.ts | 108 + tsconfig.json => apps/web/tsconfig.json | 0 docker-compose.dev.yml | 34 + docker-compose.yml | 152 +- docker/admin.Dockerfile | 23 + docker/api.Dockerfile | 38 + docker/nginx/admin.conf | 20 + docker/nginx/gateway.conf.template | 58 + docker/web.Dockerfile | 23 + nginx.conf | 27 - out/200.html | 1 - out/404.html | 1 - out/__not-found/_payload.json | 1 - out/_nuxt/BLoJslU_.js | 3 - out/_nuxt/BWho5g3a.js | 9 - out/_nuxt/CwuOmQRC.js | 112 - out/_nuxt/DGo6u42Q.js | 56 - out/_nuxt/builds/latest.json | 1 - .../9bc20942-bdb1-40fa-859b-b1c16104da2f.json | 1 - out/_nuxt/entry.DXyzAtZq.css | 2 - out/_nuxt/mbFT6iu6.js | 1 - out/_payload.json | 1 - out/categories/eye-protection/_payload.json | 1 - out/categories/eye-protection/index.html | 1 - .../hearing-protection/_payload.json | 1 - out/categories/hearing-protection/index.html | 1 - .../protective-gloves/_payload.json | 1 - out/categories/protective-gloves/index.html | 1 - .../respiratory-protection/_payload.json | 1 - .../respiratory-protection/index.html | 1 - out/categories/safety-footwear/_payload.json | 1 - out/categories/safety-footwear/index.html | 1 - out/categories/safety-helmets/_payload.json | 1 - out/categories/safety-helmets/index.html | 1 - .../traffic-equipment/_payload.json | 1 - out/categories/traffic-equipment/index.html | 1 - out/categories/workwear/_payload.json | 1 - out/categories/workwear/index.html | 1 - out/index.html | 1 - out/manifest.webmanifest | 1 - out/robots.txt | 5 - out/sitemap.xml | 57 - package-lock.json | 10463 --------------- package.json | 42 +- packages/shared/package.json | 26 + packages/shared/src/admin.ts | 129 + packages/shared/src/catalog.ts | 103 + packages/shared/src/common.ts | 43 + packages/shared/src/icons.ts | 56 + packages/shared/src/index.ts | 14 + packages/shared/src/sections.ts | 219 + packages/shared/tsconfig.json | 16 + pnpm-lock.yaml | 10822 ++++++++++++++++ pnpm-workspace.yaml | 12 + public/fonts/IRANSansWebFaNum_Black.woff2 | Bin 25776 -> 0 bytes public/fonts/IRANSansWebFaNum_Light.woff2 | Bin 28400 -> 0 bytes .../fonts/IRANSansWebFaNum_UltraLight.woff2 | Bin 28208 -> 0 bytes public/images/about.jpg | Bin 313626 -> 0 bytes public/images/hero.jpg | Bin 205537 -> 0 bytes public/images/industries/construction.jpg | Bin 110364 -> 0 bytes public/images/industries/mining.jpg | Bin 121462 -> 0 bytes public/images/industries/oil-gas.jpg | Bin 118483 -> 0 bytes public/images/industries/petrochemical.jpg | Bin 192612 -> 0 bytes public/images/industries/power-energy.jpg | Bin 78155 -> 0 bytes public/images/industries/transport.jpg | Bin 103407 -> 0 bytes public/images/logo.png | Bin 285305 -> 0 bytes public/images/products/eyewear/1.webp | Bin 23582 -> 0 bytes public/images/products/eyewear/10.webp | Bin 23790 -> 0 bytes public/images/products/eyewear/11.webp | Bin 37518 -> 0 bytes public/images/products/eyewear/2.webp | Bin 49726 -> 0 bytes public/images/products/eyewear/3.webp | Bin 63168 -> 0 bytes public/images/products/eyewear/4.webp | Bin 20030 -> 0 bytes public/images/products/eyewear/5.webp | Bin 31110 -> 0 bytes public/images/products/eyewear/6.webp | Bin 24884 -> 0 bytes public/images/products/eyewear/7.webp | Bin 21910 -> 0 bytes public/images/products/eyewear/8.webp | Bin 90986 -> 0 bytes public/images/products/eyewear/9.webp | Bin 23202 -> 0 bytes public/images/products/gloves.jpg | Bin 271323 -> 0 bytes public/images/products/gloves/acid/1.webp | Bin 38258 -> 0 bytes public/images/products/gloves/acid/2.webp | Bin 36008 -> 0 bytes public/images/products/gloves/acid/3.webp | Bin 36626 -> 0 bytes public/images/products/gloves/acid/4.webp | Bin 26014 -> 0 bytes public/images/products/gloves/cut/1.webp | Bin 58536 -> 0 bytes public/images/products/gloves/cut/2.webp | Bin 123716 -> 0 bytes public/images/products/gloves/cut/3.webp | Bin 67414 -> 0 bytes public/images/products/gloves/cut/4.webp | Bin 43022 -> 0 bytes public/images/products/gloves/cut/5.webp | Bin 74494 -> 0 bytes public/images/products/gloves/leather/1.webp | Bin 50666 -> 0 bytes public/images/products/gloves/leather/2.webp | Bin 26234 -> 0 bytes public/images/products/gloves/leather/3.webp | Bin 48248 -> 0 bytes public/images/products/gloves/leather/4.webp | Bin 49172 -> 0 bytes public/images/products/gloves/leather/5.webp | Bin 77564 -> 0 bytes public/images/products/gloves/leather/6.webp | Bin 60626 -> 0 bytes public/images/products/gloves/solvent/1.webp | Bin 35410 -> 0 bytes public/images/products/gloves/solvent/2.webp | Bin 25356 -> 0 bytes public/images/products/gloves/solvent/3.webp | Bin 31406 -> 0 bytes public/images/products/gloves/solvent/4.webp | Bin 19870 -> 0 bytes public/images/products/goggles.jpg | Bin 135473 -> 0 bytes public/images/products/harness.jpg | Bin 249829 -> 0 bytes public/images/products/hearing.jpg | Bin 103388 -> 0 bytes public/images/products/hearing/1.webp | Bin 45140 -> 0 bytes public/images/products/hearing/2.webp | Bin 57024 -> 0 bytes public/images/products/hearing/3.webp | Bin 37216 -> 0 bytes public/images/products/hearing/4.webp | Bin 38598 -> 0 bytes public/images/products/hearing/5.webp | Bin 42846 -> 0 bytes public/images/products/hearing/6.webp | Bin 26742 -> 0 bytes public/images/products/hearing/7.webp | Bin 34352 -> 0 bytes public/images/products/hearing/8.webp | Bin 54606 -> 0 bytes public/images/products/helmet.jpg | Bin 126246 -> 0 bytes public/images/products/helmets/1.webp | Bin 30134 -> 0 bytes public/images/products/helmets/10.webp | Bin 27568 -> 0 bytes public/images/products/helmets/11.webp | Bin 18754 -> 0 bytes public/images/products/helmets/12.webp | Bin 18264 -> 0 bytes public/images/products/helmets/2.webp | Bin 28052 -> 0 bytes public/images/products/helmets/3.webp | Bin 58032 -> 0 bytes public/images/products/helmets/4.webp | Bin 46104 -> 0 bytes public/images/products/helmets/5.webp | Bin 21700 -> 0 bytes public/images/products/helmets/9.webp | Bin 9624 -> 0 bytes public/images/products/respiratory.jpg | Bin 126322 -> 0 bytes public/images/products/respiratory/1.webp | Bin 27396 -> 0 bytes public/images/products/respiratory/10.webp | Bin 12636 -> 0 bytes public/images/products/respiratory/11.webp | Bin 13922 -> 0 bytes public/images/products/respiratory/2.webp | Bin 66870 -> 0 bytes public/images/products/respiratory/3.webp | Bin 39266 -> 0 bytes public/images/products/respiratory/4.webp | Bin 76410 -> 0 bytes public/images/products/respiratory/5.webp | Bin 39744 -> 0 bytes public/images/products/respiratory/6.webp | Bin 61814 -> 0 bytes public/images/products/respiratory/7.webp | Bin 92784 -> 0 bytes public/images/products/respiratory/8.webp | Bin 70326 -> 0 bytes public/images/products/respiratory/9.webp | Bin 49328 -> 0 bytes public/images/products/shoes.jpg | Bin 195578 -> 0 bytes public/images/products/shoes/office/1.webp | Bin 12612 -> 0 bytes public/images/products/shoes/office/2.webp | Bin 15364 -> 0 bytes public/images/products/shoes/office/3.webp | Bin 37384 -> 0 bytes public/images/products/shoes/office/4.webp | Bin 9408 -> 0 bytes public/images/products/shoes/office/5.webp | Bin 12682 -> 0 bytes public/images/products/shoes/office/6.webp | Bin 29878 -> 0 bytes public/images/products/shoes/office/7.webp | Bin 20288 -> 0 bytes public/images/products/shoes/office/8.webp | Bin 7124 -> 0 bytes public/images/products/shoes/worker/1.png | Bin 781359 -> 0 bytes public/images/products/shoes/worker/2.png | Bin 726475 -> 0 bytes public/images/products/shoes/worker/3.png | Bin 1356700 -> 0 bytes public/images/products/shoes/worker/4.png | Bin 1197011 -> 0 bytes public/images/products/shoes/worker/5.png | Bin 527958 -> 0 bytes public/images/products/shoes/worker/6.png | Bin 1070533 -> 0 bytes public/images/products/traffic.jpg | Bin 118521 -> 0 bytes public/images/products/vest.jpg | Bin 202717 -> 0 bytes shared/config/site.ts | 42 - shared/seo/schema.ts | 99 - turbo.json | 23 + 395 files changed, 19070 insertions(+), 11592 deletions(-) create mode 100644 .env.example create mode 100644 .prettierignore create mode 100644 .prettierrc delete mode 100644 Dockerfile delete mode 100644 app/composables/useSiteSeo.ts delete mode 100644 app/stores/contact.ts create mode 100644 apps/admin/index.html create mode 100644 apps/admin/package.json rename {out => apps/admin/public}/favicon.svg (100%) rename {out => apps/admin/public}/fonts/IRANSansWebFaNum.woff2 (100%) rename {out => apps/admin/public}/fonts/IRANSansWebFaNum_Bold.woff2 (100%) rename {out => apps/admin/public}/fonts/IRANSansWebFaNum_Medium.woff2 (100%) create mode 100644 apps/admin/src/App.vue create mode 100644 apps/admin/src/api/http.ts create mode 100644 apps/admin/src/api/types.ts create mode 100644 apps/admin/src/components/FormField.vue create mode 100644 apps/admin/src/components/IconSelect.vue create mode 100644 apps/admin/src/components/ImageField.vue create mode 100644 apps/admin/src/components/ImageRefField.vue create mode 100644 apps/admin/src/components/ListEditor.vue create mode 100644 apps/admin/src/components/MediaPicker.vue create mode 100644 apps/admin/src/components/PageHeader.vue create mode 100644 apps/admin/src/components/SectionHeadFields.vue create mode 100644 apps/admin/src/components/icons.ts create mode 100644 apps/admin/src/composables/useZodForm.ts create mode 100644 apps/admin/src/layouts/AdminLayout.vue create mode 100644 apps/admin/src/main.ts create mode 100644 apps/admin/src/pages/CategoriesPage.vue create mode 100644 apps/admin/src/pages/CategoryEditPage.vue create mode 100644 apps/admin/src/pages/DashboardPage.vue create mode 100644 apps/admin/src/pages/LoginPage.vue create mode 100644 apps/admin/src/pages/MediaPage.vue create mode 100644 apps/admin/src/pages/MessagesPage.vue create mode 100644 apps/admin/src/pages/ProductEditPage.vue create mode 100644 apps/admin/src/pages/ProductsPage.vue create mode 100644 apps/admin/src/pages/ProfilePage.vue create mode 100644 apps/admin/src/pages/SectionEditPage.vue create mode 100644 apps/admin/src/pages/SectionsPage.vue create mode 100644 apps/admin/src/pages/UsersPage.vue create mode 100644 apps/admin/src/router.ts create mode 100644 apps/admin/src/sections/AboutForm.vue create mode 100644 apps/admin/src/sections/ContactForm.vue create mode 100644 apps/admin/src/sections/FooterForm.vue create mode 100644 apps/admin/src/sections/HeroForm.vue create mode 100644 apps/admin/src/sections/IndustriesForm.vue create mode 100644 apps/admin/src/sections/ProductsIntroForm.vue create mode 100644 apps/admin/src/sections/ServicesForm.vue create mode 100644 apps/admin/src/sections/SettingsForm.vue create mode 100644 apps/admin/src/sections/StandardsForm.vue create mode 100644 apps/admin/src/sections/StatsForm.vue create mode 100644 apps/admin/src/sections/TapeForm.vue create mode 100644 apps/admin/src/sections/TestimonialsForm.vue create mode 100644 apps/admin/src/stores/auth.ts create mode 100644 apps/admin/src/style.css create mode 100644 apps/admin/src/utils/format.ts create mode 100644 apps/admin/src/utils/slug.ts create mode 100644 apps/admin/tsconfig.app.json create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/tsconfig.node.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/api/.env.example create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma.config.ts create mode 100644 apps/api/prisma/migrations/20261021130147_init/migration.sql create mode 100644 apps/api/prisma/migrations/migration_lock.toml create mode 100644 apps/api/prisma/schema.prisma rename {out/images => apps/api/seed-assets}/about.jpg (100%) rename {out/images => apps/api/seed-assets}/hero.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/construction.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/mining.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/oil-gas.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/petrochemical.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/power-energy.jpg (100%) rename {out/images => apps/api/seed-assets}/industries/transport.jpg (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/10.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/11.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/6.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/7.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/8.webp (100%) rename {out/images => apps/api/seed-assets}/products/eyewear/9.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves.jpg (100%) rename {out/images => apps/api/seed-assets}/products/gloves/acid/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/acid/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/acid/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/acid/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/cut/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/cut/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/cut/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/cut/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/cut/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/leather/6.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/solvent/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/solvent/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/solvent/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/gloves/solvent/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/goggles.jpg (100%) rename {out/images => apps/api/seed-assets}/products/harness.jpg (100%) rename {out/images => apps/api/seed-assets}/products/hearing.jpg (100%) rename {out/images => apps/api/seed-assets}/products/hearing/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/6.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/7.webp (100%) rename {out/images => apps/api/seed-assets}/products/hearing/8.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmet.jpg (100%) rename {out/images => apps/api/seed-assets}/products/helmets/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/10.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/11.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/12.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/helmets/9.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory.jpg (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/10.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/11.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/6.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/7.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/8.webp (100%) rename {out/images => apps/api/seed-assets}/products/respiratory/9.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes.jpg (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/1.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/2.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/3.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/4.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/5.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/6.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/7.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/office/8.webp (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/1.png (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/2.png (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/3.png (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/4.png (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/5.png (100%) rename {out/images => apps/api/seed-assets}/products/shoes/worker/6.png (100%) rename {out/images => apps/api/seed-assets}/products/traffic.jpg (100%) rename {out/images => apps/api/seed-assets}/products/vest.jpg (100%) create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/catalog/catalog.module.ts create mode 100644 apps/api/src/catalog/categories.service.ts create mode 100644 apps/api/src/catalog/products.service.ts create mode 100644 apps/api/src/common/auth.ts create mode 100644 apps/api/src/common/common.module.ts create mode 100644 apps/api/src/common/prisma-exception.filter.ts create mode 100644 apps/api/src/common/zod.pipe.ts create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/contact/contact.module.ts create mode 100644 apps/api/src/dashboard/dashboard.module.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/media/media.controller.ts create mode 100644 apps/api/src/media/media.service.ts create mode 100644 apps/api/src/media/s3-storage.ts create mode 100644 apps/api/src/prisma/prisma.service.ts create mode 100644 apps/api/src/public/public.module.ts create mode 100644 apps/api/src/sections/sections.module.ts rename {shared => apps/api/src/seed}/data/categories.ts (99%) create mode 100644 apps/api/src/seed/data/sections.ts create mode 100644 apps/api/src/seed/seed.ts create mode 100644 apps/api/src/users/users.controller.ts create mode 100644 apps/api/test/api.e2e-spec.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/vitest.config.e2e.ts create mode 100644 apps/web/.env.example rename {app => apps/web/app}/app.vue (76%) rename {app => apps/web/app}/assets/css/antd-overrides.css (100%) rename {app => apps/web/app}/assets/css/main.css (100%) rename {app => apps/web/app}/components/layout/AppShell.vue (100%) rename {app => apps/web/app}/components/layout/BackToTop.vue (100%) rename {app => apps/web/app}/components/layout/Cursor.vue (100%) rename {app => apps/web/app}/components/layout/Footer.vue (55%) rename {app => apps/web/app}/components/layout/Header.vue (96%) rename {app => apps/web/app}/components/layout/Preloader.vue (100%) rename {app => apps/web/app}/components/sections/About.vue (54%) rename {app => apps/web/app}/components/sections/Contact.vue (74%) rename {app => apps/web/app}/components/sections/CountUp.vue (100%) rename {app => apps/web/app}/components/sections/Hero.vue (80%) rename {app => apps/web/app}/components/sections/Industries.vue (69%) rename {app => apps/web/app}/components/sections/Products.vue (70%) rename {app => apps/web/app}/components/sections/Services.vue (51%) rename {app => apps/web/app}/components/sections/Standards.vue (68%) rename {app => apps/web/app}/components/sections/Stats.vue (72%) rename {app => apps/web/app}/components/sections/TapeMarquee.vue (66%) rename {app => apps/web/app}/components/sections/Testimonials.vue (61%) create mode 100644 apps/web/app/components/ui/DynamicIcon.vue rename {app => apps/web/app}/components/ui/ImagePlaceholder.vue (100%) create mode 100644 apps/web/app/components/ui/Lines.vue rename {app => apps/web/app}/components/ui/Logo.vue (100%) rename {app => apps/web/app}/components/ui/Reveal.vue (100%) rename {app => apps/web/app}/components/ui/SectionHead.vue (70%) rename {app => apps/web/app}/composables/useJsonLd.ts (100%) rename {app => apps/web/app}/composables/useScrollY.ts (100%) create mode 100644 apps/web/app/composables/useSeoContext.ts create mode 100644 apps/web/app/composables/useSiteSeo.ts rename {app => apps/web/app}/composables/useTehranClock.ts (100%) rename {app => apps/web/app}/design/theme.ts (88%) rename {app => apps/web/app}/error.vue (100%) rename {app => apps/web/app}/pages/categories/[slug].vue (87%) rename {app => apps/web/app}/pages/index.vue (87%) rename {app => apps/web/app}/plugins/antd-style.ts (100%) create mode 100644 apps/web/app/plugins/api.ts create mode 100644 apps/web/app/stores/contact.ts create mode 100644 apps/web/app/stores/site.ts rename {app => apps/web/app}/stores/ui.ts (100%) rename {app => apps/web/app}/utils/motion.ts (100%) rename nuxt.config.ts => apps/web/nuxt.config.ts (53%) create mode 100644 apps/web/package.json rename {public => apps/web/public}/favicon.svg (100%) rename {public => apps/web/public}/fonts/IRANSansWebFaNum.woff2 (100%) rename {out => apps/web/public}/fonts/IRANSansWebFaNum_Black.woff2 (100%) rename {public => apps/web/public}/fonts/IRANSansWebFaNum_Bold.woff2 (100%) rename {out => apps/web/public}/fonts/IRANSansWebFaNum_Light.woff2 (100%) rename {public => apps/web/public}/fonts/IRANSansWebFaNum_Medium.woff2 (100%) rename {out => apps/web/public}/fonts/IRANSansWebFaNum_UltraLight.woff2 (100%) rename {out => apps/web/public}/images/logo.png (100%) create mode 100644 apps/web/server/routes/_internal/revalidate.post.ts rename {server => apps/web/server}/routes/manifest.webmanifest.ts (69%) rename {server => apps/web/server}/routes/robots.txt.ts (57%) rename {server => apps/web/server}/routes/sitemap.xml.ts (53%) create mode 100644 apps/web/server/utils/api.ts create mode 100644 apps/web/shared/config/site.ts create mode 100644 apps/web/shared/seo/schema.ts rename tsconfig.json => apps/web/tsconfig.json (100%) create mode 100644 docker-compose.dev.yml create mode 100644 docker/admin.Dockerfile create mode 100644 docker/api.Dockerfile create mode 100644 docker/nginx/admin.conf create mode 100644 docker/nginx/gateway.conf.template create mode 100644 docker/web.Dockerfile delete mode 100644 nginx.conf delete mode 100644 out/200.html delete mode 100644 out/404.html delete mode 100644 out/__not-found/_payload.json delete mode 100644 out/_nuxt/BLoJslU_.js delete mode 100644 out/_nuxt/BWho5g3a.js delete mode 100644 out/_nuxt/CwuOmQRC.js delete mode 100644 out/_nuxt/DGo6u42Q.js delete mode 100644 out/_nuxt/builds/latest.json delete mode 100644 out/_nuxt/builds/meta/9bc20942-bdb1-40fa-859b-b1c16104da2f.json delete mode 100644 out/_nuxt/entry.DXyzAtZq.css delete mode 100644 out/_nuxt/mbFT6iu6.js delete mode 100644 out/_payload.json delete mode 100644 out/categories/eye-protection/_payload.json delete mode 100644 out/categories/eye-protection/index.html delete mode 100644 out/categories/hearing-protection/_payload.json delete mode 100644 out/categories/hearing-protection/index.html delete mode 100644 out/categories/protective-gloves/_payload.json delete mode 100644 out/categories/protective-gloves/index.html delete mode 100644 out/categories/respiratory-protection/_payload.json delete mode 100644 out/categories/respiratory-protection/index.html delete mode 100644 out/categories/safety-footwear/_payload.json delete mode 100644 out/categories/safety-footwear/index.html delete mode 100644 out/categories/safety-helmets/_payload.json delete mode 100644 out/categories/safety-helmets/index.html delete mode 100644 out/categories/traffic-equipment/_payload.json delete mode 100644 out/categories/traffic-equipment/index.html delete mode 100644 out/categories/workwear/_payload.json delete mode 100644 out/categories/workwear/index.html delete mode 100644 out/index.html delete mode 100644 out/manifest.webmanifest delete mode 100644 out/robots.txt delete mode 100644 out/sitemap.xml delete mode 100644 package-lock.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/admin.ts create mode 100644 packages/shared/src/catalog.ts create mode 100644 packages/shared/src/common.ts create mode 100644 packages/shared/src/icons.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/sections.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml delete mode 100644 public/fonts/IRANSansWebFaNum_Black.woff2 delete mode 100644 public/fonts/IRANSansWebFaNum_Light.woff2 delete mode 100644 public/fonts/IRANSansWebFaNum_UltraLight.woff2 delete mode 100644 public/images/about.jpg delete mode 100644 public/images/hero.jpg delete mode 100644 public/images/industries/construction.jpg delete mode 100644 public/images/industries/mining.jpg delete mode 100644 public/images/industries/oil-gas.jpg delete mode 100644 public/images/industries/petrochemical.jpg delete mode 100644 public/images/industries/power-energy.jpg delete mode 100644 public/images/industries/transport.jpg delete mode 100644 public/images/logo.png delete mode 100644 public/images/products/eyewear/1.webp delete mode 100644 public/images/products/eyewear/10.webp delete mode 100644 public/images/products/eyewear/11.webp delete mode 100644 public/images/products/eyewear/2.webp delete mode 100644 public/images/products/eyewear/3.webp delete mode 100644 public/images/products/eyewear/4.webp delete mode 100644 public/images/products/eyewear/5.webp delete mode 100644 public/images/products/eyewear/6.webp delete mode 100644 public/images/products/eyewear/7.webp delete mode 100644 public/images/products/eyewear/8.webp delete mode 100644 public/images/products/eyewear/9.webp delete mode 100644 public/images/products/gloves.jpg delete mode 100644 public/images/products/gloves/acid/1.webp delete mode 100644 public/images/products/gloves/acid/2.webp delete mode 100644 public/images/products/gloves/acid/3.webp delete mode 100644 public/images/products/gloves/acid/4.webp delete mode 100644 public/images/products/gloves/cut/1.webp delete mode 100644 public/images/products/gloves/cut/2.webp delete mode 100644 public/images/products/gloves/cut/3.webp delete mode 100644 public/images/products/gloves/cut/4.webp delete mode 100644 public/images/products/gloves/cut/5.webp delete mode 100644 public/images/products/gloves/leather/1.webp delete mode 100644 public/images/products/gloves/leather/2.webp delete mode 100644 public/images/products/gloves/leather/3.webp delete mode 100644 public/images/products/gloves/leather/4.webp delete mode 100644 public/images/products/gloves/leather/5.webp delete mode 100644 public/images/products/gloves/leather/6.webp delete mode 100644 public/images/products/gloves/solvent/1.webp delete mode 100644 public/images/products/gloves/solvent/2.webp delete mode 100644 public/images/products/gloves/solvent/3.webp delete mode 100644 public/images/products/gloves/solvent/4.webp delete mode 100644 public/images/products/goggles.jpg delete mode 100644 public/images/products/harness.jpg delete mode 100644 public/images/products/hearing.jpg delete mode 100644 public/images/products/hearing/1.webp delete mode 100644 public/images/products/hearing/2.webp delete mode 100644 public/images/products/hearing/3.webp delete mode 100644 public/images/products/hearing/4.webp delete mode 100644 public/images/products/hearing/5.webp delete mode 100644 public/images/products/hearing/6.webp delete mode 100644 public/images/products/hearing/7.webp delete mode 100644 public/images/products/hearing/8.webp delete mode 100644 public/images/products/helmet.jpg delete mode 100644 public/images/products/helmets/1.webp delete mode 100644 public/images/products/helmets/10.webp delete mode 100644 public/images/products/helmets/11.webp delete mode 100644 public/images/products/helmets/12.webp delete mode 100644 public/images/products/helmets/2.webp delete mode 100644 public/images/products/helmets/3.webp delete mode 100644 public/images/products/helmets/4.webp delete mode 100644 public/images/products/helmets/5.webp delete mode 100644 public/images/products/helmets/9.webp delete mode 100644 public/images/products/respiratory.jpg delete mode 100644 public/images/products/respiratory/1.webp delete mode 100644 public/images/products/respiratory/10.webp delete mode 100644 public/images/products/respiratory/11.webp delete mode 100644 public/images/products/respiratory/2.webp delete mode 100644 public/images/products/respiratory/3.webp delete mode 100644 public/images/products/respiratory/4.webp delete mode 100644 public/images/products/respiratory/5.webp delete mode 100644 public/images/products/respiratory/6.webp delete mode 100644 public/images/products/respiratory/7.webp delete mode 100644 public/images/products/respiratory/8.webp delete mode 100644 public/images/products/respiratory/9.webp delete mode 100644 public/images/products/shoes.jpg delete mode 100644 public/images/products/shoes/office/1.webp delete mode 100644 public/images/products/shoes/office/2.webp delete mode 100644 public/images/products/shoes/office/3.webp delete mode 100644 public/images/products/shoes/office/4.webp delete mode 100644 public/images/products/shoes/office/5.webp delete mode 100644 public/images/products/shoes/office/6.webp delete mode 100644 public/images/products/shoes/office/7.webp delete mode 100644 public/images/products/shoes/office/8.webp delete mode 100644 public/images/products/shoes/worker/1.png delete mode 100644 public/images/products/shoes/worker/2.png delete mode 100644 public/images/products/shoes/worker/3.png delete mode 100644 public/images/products/shoes/worker/4.png delete mode 100644 public/images/products/shoes/worker/5.png delete mode 100644 public/images/products/shoes/worker/6.png delete mode 100644 public/images/products/traffic.jpg delete mode 100644 public/images/products/vest.jpg delete mode 100644 shared/config/site.ts delete mode 100644 shared/seo/schema.ts create mode 100644 turbo.json diff --git a/.dockerignore b/.dockerignore index 70a68db..6cce923 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,16 @@ -node_modules -.nuxt -.output +**/node_modules +**/dist +**/.output +**/.nuxt +**/.turbo +**/*.tsbuildinfo +**/.env +**/.env.* +!**/.env.example +apps/api/src/generated .git -*.md +out +backups +محصولات _dev.log -.env* -npm-debug.log* -logo.png +*.skill diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e5ec838 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Production settings for docker-compose.yml — copy to .env and change every secret. +# Generate secrets with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" + +# domains (TLS is terminated in front of the gateway: host nginx, Traefik or the CDN) +SITE_DOMAIN=imenpishgaman.ir +ADMIN_DOMAIN=admin.imenpishgaman.ir +# host port of the gateway container +HTTP_PORT=8080 +# reverse proxies in front of the API: gateway (1) + host TLS proxy (1) +TRUST_PROXY=2 +# set to false only if the admin is served over plain http +COOKIE_SECURE=true + +# database +POSTGRES_USER=imen +POSTGRES_PASSWORD=change-me +POSTGRES_DB=imen + +# object storage (MinIO container); bucket is created automatically +S3_BUCKET=imen-media +S3_ACCESS_KEY=imen +S3_SECRET_KEY=change-me-at-least-8-chars + +# auth + cache purge secrets +JWT_SECRET=change-me-48-random-bytes +REVALIDATE_SECRET=change-me-too + +# first admin user (created by: docker compose exec api node dist/seed/seed.js) +ADMIN_EMAIL=admin@imenpishgaman.ir +ADMIN_PASSWORD=change-me +ADMIN_NAME=مدیر سایت + +# npm registry used while building the images (set a mirror if npmjs.org is slow or blocked) +NPM_REGISTRY=https://registry.npmjs.org/ diff --git a/.gitignore b/.gitignore index 6aa258b..a6668a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ # dependencies node_modules/ -# nuxt -.nuxt/ +# build output +dist/ .output/ +.nuxt/ .data/ -dist +.turbo/ +*.tsbuildinfo +# prisma generated client +apps/api/src/generated/ # misc .DS_Store @@ -14,7 +18,12 @@ dist # debug npm-debug.log* +pnpm-debug.log* # local env files .env .env*.local +!.env.example + +# database dumps from the backup service +backups/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..73828a6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +out +dist +.output +.nuxt +pnpm-lock.yaml +apps/api/src/generated +محصولات diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index c43b092..0000000 --- a/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -# Static export is built on the host (`npm run build` → ./out) -# This image only serves the pre-built files via nginx. -FROM docker.arvancloud.ir/nginx:alpine - -COPY out /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index dde3588..a93aa4d 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,154 @@ -# آریا ایمن — لندینگ (Nuxt) +# ایمن پیشگامان — مونوریپو (سایت + API + پنل مدیریت) -لندینگ شرکت آریا ایمن، مهاجرت‌یافته از Next.js به **Nuxt 4 (Vue 3)** با خروجی کاملاً استاتیک (SSG)، بهینه برای سئو و سرعت. -ظاهر و رفتار صفحه دقیقاً همان نسخه Next.js است. +سایت ایمن پیشگامان به‌همراه بکند و پنل مدیریت محتوا. همه‌ی متن‌ها، تصاویر، دسته‌ها و محصولات سایت از پنل مدیریت قابل ویرایش‌اند. -| لایه | ابزار | -|---|---| -| فریم‌ورک | Nuxt 4 + Vue 3 (` + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..ab6fe89 --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@imen/admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 5173", + "build": "vue-tsc -b && vite build", + "preview": "vite preview --port 5173", + "typecheck": "vue-tsc -b" + }, + "dependencies": { + "@ant-design/icons-vue": "^7.0.1", + "@imen/shared": "workspace:*", + "ant-design-vue": "^4.2.6", + "lucide-vue-next": "^0.475.0", + "ofetch": "^1.5.1", + "pinia": "^4.0.3", + "vue": "^3.5.43", + "vue-router": "^5.3.1", + "zod": "^4.6.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.17", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.10", + "@vue/tsconfig": "^0.8.1", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "vite": "^8.0.0", + "vue-tsc": "^3.3.12" + } +} diff --git a/out/favicon.svg b/apps/admin/public/favicon.svg similarity index 100% rename from out/favicon.svg rename to apps/admin/public/favicon.svg diff --git a/out/fonts/IRANSansWebFaNum.woff2 b/apps/admin/public/fonts/IRANSansWebFaNum.woff2 similarity index 100% rename from out/fonts/IRANSansWebFaNum.woff2 rename to apps/admin/public/fonts/IRANSansWebFaNum.woff2 diff --git a/out/fonts/IRANSansWebFaNum_Bold.woff2 b/apps/admin/public/fonts/IRANSansWebFaNum_Bold.woff2 similarity index 100% rename from out/fonts/IRANSansWebFaNum_Bold.woff2 rename to apps/admin/public/fonts/IRANSansWebFaNum_Bold.woff2 diff --git a/out/fonts/IRANSansWebFaNum_Medium.woff2 b/apps/admin/public/fonts/IRANSansWebFaNum_Medium.woff2 similarity index 100% rename from out/fonts/IRANSansWebFaNum_Medium.woff2 rename to apps/admin/public/fonts/IRANSansWebFaNum_Medium.woff2 diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue new file mode 100644 index 0000000..717f195 --- /dev/null +++ b/apps/admin/src/App.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/admin/src/api/http.ts b/apps/admin/src/api/http.ts new file mode 100644 index 0000000..66c6e90 --- /dev/null +++ b/apps/admin/src/api/http.ts @@ -0,0 +1,59 @@ +import { FetchError, type FetchOptions, ofetch } from "ofetch"; +import type { ValidationIssue } from "@imen/shared"; + +/** Raw client without auth handling (login / refresh / logout use it directly) */ +export const rawApi = ofetch.create({ baseURL: "/api", credentials: "include" }); + +type AuthHooks = { + token: () => string; + refresh: () => Promise; + onExpired: () => void; +}; +let hooks: AuthHooks | null = null; + +/** Wired by the auth store, which owns the access token */ +export const setAuthHooks = (h: AuthHooks) => { + hooks = h; +}; + +/** + * Authenticated request. On 401 it refreshes the access token once (using the httpOnly + * refresh cookie) and retries; if that fails the session is over. + */ +export async function api(url: string, opts: FetchOptions<"json"> = {}, retried = false): Promise { + const token = hooks?.token(); + try { + return await rawApi(url, { + ...opts, + headers: { ...(opts.headers as Record), ...(token && { Authorization: `Bearer ${token}` }) }, + }); + } catch (e) { + if (e instanceof FetchError && e.status === 401 && !retried && hooks) { + if (await hooks.refresh()) return api(url, opts, true); + hooks.onExpired(); + } + throw e; + } +} + +interface ApiErrorBody { + message?: string | string[]; + issues?: ValidationIssue[]; +} + +export const errorBody = (e: unknown): ApiErrorBody => + e instanceof FetchError ? ((e.data as ApiErrorBody | undefined) ?? {}) : {}; + +/** Human-readable (Persian) error message for notifications */ +export function errorMessage(e: unknown): string { + if (e instanceof FetchError) { + if (!e.status) return "ارتباط با سرور برقرار نشد"; + const msg = errorBody(e).message; + if (Array.isArray(msg)) return msg.join("، "); + if (msg) return msg; + if (e.status === 413) return "حجم فایل بیش از حد مجاز است"; + if (e.status === 429) return "تعداد درخواست‌ها زیاد است؛ کمی بعد تلاش کنید"; + return `خطای سرور (${e.status})`; + } + return "خطای ناشناخته"; +} diff --git a/apps/admin/src/api/types.ts b/apps/admin/src/api/types.ts new file mode 100644 index 0000000..b9956b9 --- /dev/null +++ b/apps/admin/src/api/types.ts @@ -0,0 +1,65 @@ +import type { MediaDto, SectionKey, SiteSections } from "@imen/shared"; + +/** Shapes returned by the admin endpoints of the API */ + +export interface AdminCategory { + id: number; + slug: string; + code: string; + name: string; + nameEn: string; + shortDesc: string; + fullDesc: string; + icon: string; + coverImageId: number | null; + coverImage: MediaDto | null; + sortOrder: number; + isPublished: boolean; + seoTitle: string; + seoDescription: string; + productCount: number; + groupCount: number; +} + +export interface AdminProductGroup { + id: number; + slug: string; + name: string; + desc: string; + sortOrder: number; + productCount: number; +} + +export interface AdminCategoryDetail extends Omit { + groups: AdminProductGroup[]; +} + +export interface AdminProduct { + id: number; + categoryId: number; + groupId: number | null; + code: string; + slug: string; + name: string; + nameEn: string; + desc: string; + specs: string[]; + variants: string[]; + imageId: number | null; + image: MediaDto | null; + sortOrder: number; + isPublished: boolean; + category: { id: number; name: string }; + group: { id: number; name: string } | null; + updatedAt: string; +} + +export interface AdminProductDetail extends Omit { + gallery: MediaDto[]; +} + +export interface SectionResponse { + key: K; + data: SiteSections[K]; + updatedAt: string; +} diff --git a/apps/admin/src/components/FormField.vue b/apps/admin/src/components/FormField.vue new file mode 100644 index 0000000..604ecda --- /dev/null +++ b/apps/admin/src/components/FormField.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/admin/src/components/IconSelect.vue b/apps/admin/src/components/IconSelect.vue new file mode 100644 index 0000000..2ae45da --- /dev/null +++ b/apps/admin/src/components/IconSelect.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/admin/src/components/ImageField.vue b/apps/admin/src/components/ImageField.vue new file mode 100644 index 0000000..96bc1db --- /dev/null +++ b/apps/admin/src/components/ImageField.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/admin/src/components/ImageRefField.vue b/apps/admin/src/components/ImageRefField.vue new file mode 100644 index 0000000..904860b --- /dev/null +++ b/apps/admin/src/components/ImageRefField.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/admin/src/components/ListEditor.vue b/apps/admin/src/components/ListEditor.vue new file mode 100644 index 0000000..5cc5f46 --- /dev/null +++ b/apps/admin/src/components/ListEditor.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/admin/src/components/MediaPicker.vue b/apps/admin/src/components/MediaPicker.vue new file mode 100644 index 0000000..a86bb92 --- /dev/null +++ b/apps/admin/src/components/MediaPicker.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/admin/src/components/PageHeader.vue b/apps/admin/src/components/PageHeader.vue new file mode 100644 index 0000000..a8bcf69 --- /dev/null +++ b/apps/admin/src/components/PageHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/admin/src/components/SectionHeadFields.vue b/apps/admin/src/components/SectionHeadFields.vue new file mode 100644 index 0000000..3119e12 --- /dev/null +++ b/apps/admin/src/components/SectionHeadFields.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/admin/src/components/icons.ts b/apps/admin/src/components/icons.ts new file mode 100644 index 0000000..ee26e26 --- /dev/null +++ b/apps/admin/src/components/icons.ts @@ -0,0 +1,104 @@ +import type { IconName } from "@imen/shared"; +import { + Award, + BadgeCheck, + Building2, + ClipboardCheck, + Clock, + Construction, + Ear, + Factory, + FileCheck, + FileSearch, + Flame, + Footprints, + Fuel, + Gauge, + Glasses, + Globe, + GraduationCap, + Hand, + Handshake, + HardHat, + Headset, + HeartPulse, + Leaf, + Lock, + Mail, + MapPin, + Medal, + Microscope, + Package, + PackageCheck, + Phone, + Pickaxe, + ShieldAlert, + ShieldCheck, + ShieldPlus, + Shirt, + Siren, + Star, + Target, + ThumbsUp, + TrafficCone, + TriangleAlert, + Truck, + Users, + Warehouse, + Wind, + Wrench, + Zap, +} from "lucide-vue-next"; +import type { Component } from "vue"; + +/** Same icon set as the website's DynamicIcon (ICON_NAMES in @imen/shared) */ +export const ICONS: Record = { + ShieldCheck, + ShieldPlus, + ShieldAlert, + BadgeCheck, + Award, + Medal, + PackageCheck, + Package, + Truck, + Warehouse, + Globe, + Users, + Handshake, + Headset, + ClipboardCheck, + FileCheck, + FileSearch, + GraduationCap, + Gauge, + Microscope, + Wrench, + HardHat, + Shirt, + Footprints, + Hand, + Glasses, + Ear, + Wind, + TriangleAlert, + Siren, + TrafficCone, + Construction, + Factory, + Building2, + Fuel, + Pickaxe, + Flame, + Zap, + Leaf, + HeartPulse, + Clock, + Phone, + Mail, + MapPin, + Star, + ThumbsUp, + Target, + Lock, +}; diff --git a/apps/admin/src/composables/useZodForm.ts b/apps/admin/src/composables/useZodForm.ts new file mode 100644 index 0000000..ed3c554 --- /dev/null +++ b/apps/admin/src/composables/useZodForm.ts @@ -0,0 +1,47 @@ +import { toIssues, type ValidationIssue } from "@imen/shared"; +import { message } from "ant-design-vue"; +import { type InjectionKey, provide, type Ref, ref } from "vue"; +import type { z } from "zod"; +import { errorBody, errorMessage } from "@/api/http"; + +/** Field errors keyed by dot path ("items.2.title"), read by */ +export const FORM_ERRORS: InjectionKey>> = Symbol("form-errors"); + +const toMap = (issues: ValidationIssue[]) => + Object.fromEntries(issues.map((i) => [i.path, i.message])); + +/** + * Form state validated with the same zod schema the API uses. + * The model is typed as the schema output (every field present) since forms start fully filled. + * `validate()` returns parsed data or null (and marks the invalid fields). + */ +export function useZodForm(schema: S, initial: z.output) { + const model = ref(structuredClone(initial)) as Ref>; + const errors = ref>({}); + provide(FORM_ERRORS, errors); + + function validate(): z.output | null { + const result = schema.safeParse(model.value); + if (result.success) { + errors.value = {}; + return result.data; + } + errors.value = toMap(toIssues(result.error)); + void message.error("لطفاً خطاهای فرم را برطرف کنید"); + return null; + } + + /** Shows an API error; validation issues from the server are attached to their fields */ + function handleError(e: unknown) { + const issues = errorBody(e).issues; + if (issues?.length) errors.value = toMap(issues); + void message.error(errorMessage(e)); + } + + function reset(value: z.output) { + model.value = structuredClone(value); + errors.value = {}; + } + + return { model, errors, validate, handleError, reset }; +} diff --git a/apps/admin/src/layouts/AdminLayout.vue b/apps/admin/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..51559d4 --- /dev/null +++ b/apps/admin/src/layouts/AdminLayout.vue @@ -0,0 +1,132 @@ + + + diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts new file mode 100644 index 0000000..bb37b4c --- /dev/null +++ b/apps/admin/src/main.ts @@ -0,0 +1,12 @@ +import Antd from "ant-design-vue"; +import { createPinia } from "pinia"; +import { createApp } from "vue"; +import { z } from "zod"; +import App from "./App.vue"; +import { router } from "./router"; +import "./style.css"; + +// Persian default validation messages (custom messages in @imen/shared still win) +z.config(z.locales.fa()); + +createApp(App).use(createPinia()).use(router).use(Antd).mount("#app"); diff --git a/apps/admin/src/pages/CategoriesPage.vue b/apps/admin/src/pages/CategoriesPage.vue new file mode 100644 index 0000000..232370c --- /dev/null +++ b/apps/admin/src/pages/CategoriesPage.vue @@ -0,0 +1,132 @@ + + + diff --git a/apps/admin/src/pages/CategoryEditPage.vue b/apps/admin/src/pages/CategoryEditPage.vue new file mode 100644 index 0000000..020f84d --- /dev/null +++ b/apps/admin/src/pages/CategoryEditPage.vue @@ -0,0 +1,203 @@ + + + diff --git a/apps/admin/src/pages/DashboardPage.vue b/apps/admin/src/pages/DashboardPage.vue new file mode 100644 index 0000000..d5c7376 --- /dev/null +++ b/apps/admin/src/pages/DashboardPage.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/admin/src/pages/LoginPage.vue b/apps/admin/src/pages/LoginPage.vue new file mode 100644 index 0000000..8be7048 --- /dev/null +++ b/apps/admin/src/pages/LoginPage.vue @@ -0,0 +1,55 @@ + + + diff --git a/apps/admin/src/pages/MediaPage.vue b/apps/admin/src/pages/MediaPage.vue new file mode 100644 index 0000000..00d48c0 --- /dev/null +++ b/apps/admin/src/pages/MediaPage.vue @@ -0,0 +1,151 @@ + + + diff --git a/apps/admin/src/pages/MessagesPage.vue b/apps/admin/src/pages/MessagesPage.vue new file mode 100644 index 0000000..405f6cc --- /dev/null +++ b/apps/admin/src/pages/MessagesPage.vue @@ -0,0 +1,126 @@ + + + diff --git a/apps/admin/src/pages/ProductEditPage.vue b/apps/admin/src/pages/ProductEditPage.vue new file mode 100644 index 0000000..b10353b --- /dev/null +++ b/apps/admin/src/pages/ProductEditPage.vue @@ -0,0 +1,245 @@ + + + diff --git a/apps/admin/src/pages/ProductsPage.vue b/apps/admin/src/pages/ProductsPage.vue new file mode 100644 index 0000000..a725522 --- /dev/null +++ b/apps/admin/src/pages/ProductsPage.vue @@ -0,0 +1,266 @@ + + + diff --git a/apps/admin/src/pages/ProfilePage.vue b/apps/admin/src/pages/ProfilePage.vue new file mode 100644 index 0000000..0c70dae --- /dev/null +++ b/apps/admin/src/pages/ProfilePage.vue @@ -0,0 +1,56 @@ + + + diff --git a/apps/admin/src/pages/SectionEditPage.vue b/apps/admin/src/pages/SectionEditPage.vue new file mode 100644 index 0000000..1841212 --- /dev/null +++ b/apps/admin/src/pages/SectionEditPage.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/admin/src/pages/SectionsPage.vue b/apps/admin/src/pages/SectionsPage.vue new file mode 100644 index 0000000..0a4a5ea --- /dev/null +++ b/apps/admin/src/pages/SectionsPage.vue @@ -0,0 +1,24 @@ + + + diff --git a/apps/admin/src/pages/UsersPage.vue b/apps/admin/src/pages/UsersPage.vue new file mode 100644 index 0000000..23356b6 --- /dev/null +++ b/apps/admin/src/pages/UsersPage.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/admin/src/router.ts b/apps/admin/src/router.ts new file mode 100644 index 0000000..ae03b4f --- /dev/null +++ b/apps/admin/src/router.ts @@ -0,0 +1,57 @@ +import type { Role } from "@imen/shared"; +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "@/stores/auth"; + +declare module "vue-router" { + interface RouteMeta { + title?: string; + public?: boolean; + roles?: Role[]; + } +} + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + name: "login", + component: () => import("@/pages/LoginPage.vue"), + meta: { public: true, title: "ورود" }, + }, + { + path: "/", + component: () => import("@/layouts/AdminLayout.vue"), + children: [ + { path: "", name: "dashboard", component: () => import("@/pages/DashboardPage.vue"), meta: { title: "داشبورد" } }, + { path: "products", name: "products", component: () => import("@/pages/ProductsPage.vue"), meta: { title: "محصولات" } }, + { path: "products/new", name: "product-new", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "محصول جدید" } }, + { path: "products/:id", name: "product-edit", component: () => import("@/pages/ProductEditPage.vue"), meta: { title: "ویرایش محصول" } }, + { path: "categories", name: "categories", component: () => import("@/pages/CategoriesPage.vue"), meta: { title: "دسته‌بندی‌ها" } }, + { path: "categories/new", name: "category-new", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "دسته جدید" } }, + { path: "categories/:id", name: "category-edit", component: () => import("@/pages/CategoryEditPage.vue"), meta: { title: "ویرایش دسته" } }, + { path: "sections", name: "sections", component: () => import("@/pages/SectionsPage.vue"), meta: { title: "بخش‌های سایت" } }, + { path: "sections/:key", name: "section-edit", component: () => import("@/pages/SectionEditPage.vue"), meta: { title: "ویرایش بخش" } }, + { path: "messages", name: "messages", component: () => import("@/pages/MessagesPage.vue"), meta: { title: "پیام‌های تماس" } }, + { path: "media", name: "media", component: () => import("@/pages/MediaPage.vue"), meta: { title: "کتابخانه رسانه" } }, + { path: "users", name: "users", component: () => import("@/pages/UsersPage.vue"), meta: { title: "کاربران", roles: ["ADMIN"] } }, + { path: "profile", name: "profile", component: () => import("@/pages/ProfilePage.vue"), meta: { title: "حساب کاربری" } }, + ], + }, + { path: "/:pathMatch(.*)*", redirect: "/" }, + ], +}); + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + await auth.init(); + + if (to.meta.public) return auth.user && to.name === "login" ? { name: "dashboard" } : true; + if (!auth.user) return { name: "login", query: { redirect: to.fullPath } }; + if (to.meta.roles && !to.meta.roles.includes(auth.user.role)) return { name: "dashboard" }; + return true; +}); + +router.afterEach((to) => { + document.title = to.meta.title ? `${to.meta.title} | پنل ایمن پیشگامان` : "پنل ایمن پیشگامان"; +}); diff --git a/apps/admin/src/sections/AboutForm.vue b/apps/admin/src/sections/AboutForm.vue new file mode 100644 index 0000000..3e8364e --- /dev/null +++ b/apps/admin/src/sections/AboutForm.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/admin/src/sections/ContactForm.vue b/apps/admin/src/sections/ContactForm.vue new file mode 100644 index 0000000..596bc97 --- /dev/null +++ b/apps/admin/src/sections/ContactForm.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/admin/src/sections/FooterForm.vue b/apps/admin/src/sections/FooterForm.vue new file mode 100644 index 0000000..a0260e5 --- /dev/null +++ b/apps/admin/src/sections/FooterForm.vue @@ -0,0 +1,20 @@ + + + diff --git a/apps/admin/src/sections/HeroForm.vue b/apps/admin/src/sections/HeroForm.vue new file mode 100644 index 0000000..c67053e --- /dev/null +++ b/apps/admin/src/sections/HeroForm.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/admin/src/sections/IndustriesForm.vue b/apps/admin/src/sections/IndustriesForm.vue new file mode 100644 index 0000000..71da423 --- /dev/null +++ b/apps/admin/src/sections/IndustriesForm.vue @@ -0,0 +1,39 @@ + + + diff --git a/apps/admin/src/sections/ProductsIntroForm.vue b/apps/admin/src/sections/ProductsIntroForm.vue new file mode 100644 index 0000000..91fa401 --- /dev/null +++ b/apps/admin/src/sections/ProductsIntroForm.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/admin/src/sections/ServicesForm.vue b/apps/admin/src/sections/ServicesForm.vue new file mode 100644 index 0000000..bf3f1ab --- /dev/null +++ b/apps/admin/src/sections/ServicesForm.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/admin/src/sections/SettingsForm.vue b/apps/admin/src/sections/SettingsForm.vue new file mode 100644 index 0000000..3fe9e77 --- /dev/null +++ b/apps/admin/src/sections/SettingsForm.vue @@ -0,0 +1,132 @@ + + + diff --git a/apps/admin/src/sections/StandardsForm.vue b/apps/admin/src/sections/StandardsForm.vue new file mode 100644 index 0000000..f732f13 --- /dev/null +++ b/apps/admin/src/sections/StandardsForm.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/admin/src/sections/StatsForm.vue b/apps/admin/src/sections/StatsForm.vue new file mode 100644 index 0000000..a8b8ab2 --- /dev/null +++ b/apps/admin/src/sections/StatsForm.vue @@ -0,0 +1,30 @@ + + + diff --git a/apps/admin/src/sections/TapeForm.vue b/apps/admin/src/sections/TapeForm.vue new file mode 100644 index 0000000..ca35c76 --- /dev/null +++ b/apps/admin/src/sections/TapeForm.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/admin/src/sections/TestimonialsForm.vue b/apps/admin/src/sections/TestimonialsForm.vue new file mode 100644 index 0000000..1306c38 --- /dev/null +++ b/apps/admin/src/sections/TestimonialsForm.vue @@ -0,0 +1,29 @@ + + + diff --git a/apps/admin/src/stores/auth.ts b/apps/admin/src/stores/auth.ts new file mode 100644 index 0000000..3f0a4d3 --- /dev/null +++ b/apps/admin/src/stores/auth.ts @@ -0,0 +1,67 @@ +import type { AdminUserDto, AuthResponse, LoginInput } from "@imen/shared"; +import { defineStore } from "pinia"; +import { rawApi, setAuthHooks } from "@/api/http"; +import { router } from "@/router"; + +let refreshing: Promise | null = null; + +export const useAuthStore = defineStore("auth", { + state: () => ({ + /** short-lived access token, kept in memory only */ + token: "", + user: null as AdminUserDto | null, + /** true once the initial session check has run */ + ready: false, + }), + getters: { + isAdmin: (s) => s.user?.role === "ADMIN", + }, + actions: { + setSession(r: AuthResponse) { + this.token = r.accessToken; + this.user = r.user; + }, + clear() { + this.token = ""; + this.user = null; + }, + async login(input: LoginInput) { + this.setSession(await rawApi("/admin/auth/login", { method: "POST", body: input })); + }, + /** Exchanges the refresh cookie for a new access token; concurrent calls share one request */ + refresh(): Promise { + refreshing ??= rawApi("/admin/auth/refresh", { method: "POST" }) + .then((r) => { + this.setSession(r); + return true; + }) + .catch(() => { + this.clear(); + return false; + }) + .finally(() => { + refreshing = null; + }); + return refreshing; + }, + /** Restores the session after a page load (the refresh cookie survives reloads) */ + async init() { + if (this.ready) return; + setAuthHooks({ + token: () => this.token, + refresh: () => this.refresh(), + onExpired: () => { + this.clear(); + void router.push({ name: "login", query: { redirect: router.currentRoute.value.fullPath } }); + }, + }); + await this.refresh(); + this.ready = true; + }, + async logout() { + await rawApi("/admin/auth/logout", { method: "POST" }).catch(() => undefined); + this.clear(); + await router.push({ name: "login" }); + }, + }, +}); diff --git a/apps/admin/src/style.css b/apps/admin/src/style.css new file mode 100644 index 0000000..067cbfb --- /dev/null +++ b/apps/admin/src/style.css @@ -0,0 +1,41 @@ +@import "tailwindcss"; + +@font-face { + font-family: "IRANSans"; + src: url("/fonts/IRANSansWebFaNum.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "IRANSans"; + src: url("/fonts/IRANSansWebFaNum_Medium.woff2") format("woff2"); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: "IRANSans"; + src: url("/fonts/IRANSansWebFaNum_Bold.woff2") format("woff2"); + font-weight: 700; + font-display: swap; +} + +@theme { + --font-sans: "IRANSans", Tahoma, sans-serif; + --color-brand: #ff5a00; + --color-navy: #0b1224; + --color-navy-2: #101a32; +} + +html, +body { + background: #f5f6fa; + font-family: var(--font-sans); +} + +/* inputs that hold Latin text (codes, slugs, URLs) read left-to-right */ +.ltr-input input, +input.ltr-input, +textarea.ltr-input { + direction: ltr; + text-align: left; +} diff --git a/apps/admin/src/utils/format.ts b/apps/admin/src/utils/format.ts new file mode 100644 index 0000000..6b15136 --- /dev/null +++ b/apps/admin/src/utils/format.ts @@ -0,0 +1,44 @@ +const dateFmt = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); + +/** ISO date → Jalali date and time, e.g. «۱۴۰۴/۰۷/۲۹، ۱۶:۳۰» */ +export const jalali = (iso: string | null | undefined) => (iso ? dateFmt.format(new Date(iso)) : "—"); + +export const fileSize = (bytes: number) => + bytes < 1024 * 1024 + ? `${(bytes / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 0 })} کیلوبایت` + : `${(bytes / 1024 / 1024).toLocaleString("fa-IR", { maximumFractionDigits: 1 })} مگابایت`; + +export const faNumber = (n: number) => n.toLocaleString("fa-IR"); + +/** Website base URL for "view on site" links */ +export const SITE_URL = (import.meta.env.VITE_SITE_URL as string | undefined) ?? "http://localhost:3000"; + +const ACTION_LABELS: Record = { + create: "ایجاد", + update: "ویرایش", + delete: "حذف", + upload: "آپلود", + login: "ورود", + publish: "انتشار", + unpublish: "عدم انتشار", + duplicate: "کپی", + reorder: "مرتب‌سازی", + "change-password": "تغییر رمز", +}; +const ENTITY_LABELS: Record = { + product: "محصول", + category: "دسته", + section: "بخش سایت", + media: "رسانه", + user: "کاربر", + message: "پیام", +}; + +export const activityLabel = (action: string, entity: string) => + `${ACTION_LABELS[action] ?? action} ${ENTITY_LABELS[entity] ?? entity}`; diff --git a/apps/admin/src/utils/slug.ts b/apps/admin/src/utils/slug.ts new file mode 100644 index 0000000..fd90251 --- /dev/null +++ b/apps/admin/src/utils/slug.ts @@ -0,0 +1,7 @@ +/** "Safety Shoes 2" → "safety-shoes-2" (Latin letters and digits only) */ +export const toSlug = (text: string) => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); diff --git a/apps/admin/tsconfig.app.json b/apps/admin/tsconfig.app.json new file mode 100644 index 0000000..373b428 --- /dev/null +++ b/apps/admin/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client", "ant-design-vue/typings/global"], + "strict": true, + "noUncheckedIndexedAccess": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue"] +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/admin/tsconfig.node.json b/apps/admin/tsconfig.node.json new file mode 100644 index 0000000..9fbe614 --- /dev/null +++ b/apps/admin/tsconfig.node.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..f0cf433 --- /dev/null +++ b/apps/admin/vite.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath, URL } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import vue from "@vitejs/plugin-vue"; +import { defineConfig, type ProxyOptions } from "vite"; + +// same paths nginx serves in production: /api → NestJS, /media → object storage bucket +const proxy: Record = { + "/api": { target: "http://localhost:4000", changeOrigin: true }, + "/media": { + target: "http://localhost:9000/imen-media", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/media/, ""), + }, +}; + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + }, + server: { port: 5173, proxy }, + preview: { port: 5173, proxy }, +}); diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..3c03d41 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,38 @@ +# --- server --- +PORT=4000 +NODE_ENV=development +# comma-separated origins allowed to call the API from a browser (admin dev server, site) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# --- database (docker-compose.dev.yml) --- +DATABASE_URL=postgresql://imen:imen@localhost:5433/imen + +# --- auth --- +# generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" +JWT_SECRET=change-me-dev-secret-change-me-dev-secret +JWT_ACCESS_TTL=15m +REFRESH_TTL_DAYS=30 +# set to false only when serving the admin over plain http in production +COOKIE_SECURE=false + +# first admin user, created by `pnpm db:seed` +ADMIN_EMAIL=admin@imenpishgaman.ir +ADMIN_PASSWORD=ChangeMe123! +ADMIN_NAME=مدیر سایت + +# --- object storage (MinIO in dev, any S3-compatible service in production) --- +S3_ENDPOINT=http://localhost:9000 +S3_REGION=us-east-1 +S3_BUCKET=imen-media +S3_ACCESS_KEY=imen +S3_SECRET_KEY=imen-secret-key +S3_FORCE_PATH_STYLE=true +# create the bucket and make it publicly readable on startup (MinIO) +S3_ENSURE_BUCKET=true +# public base URL of uploaded files (no trailing slash). "/media" is proxied to the bucket +# by the site/admin dev servers and by nginx in production, so URLs are the same everywhere +MEDIA_PUBLIC_URL=/media + +# --- website cache revalidation --- +WEB_REVALIDATE_URL=http://localhost:3000/_internal/revalidate +REVALIDATE_SECRET=change-me-revalidate-secret diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..96e7b47 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,53 @@ +{ + "name": "@imen/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "prisma generate && nest build", + "dev": "nest start --watch --preserveWatchOutput", + "start": "node dist/main.js", + "typecheck": "prisma generate && tsc --noEmit -p tsconfig.json", + "test": "vitest run --config ./vitest.config.e2e.ts", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio", + "db:seed": "prisma db seed", + "postinstall": "prisma generate" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1154.0", + "@imen/shared": "workspace:*", + "@nestjs/common": "^12.2.0", + "@nestjs/core": "^12.2.0", + "@nestjs/jwt": "^12.0.3", + "@nestjs/platform-express": "^12.2.0", + "@nestjs/throttler": "^6.7.1", + "@node-rs/argon2": "^2.2.2", + "@prisma/adapter-pg": "7.10.1", + "@prisma/client": "7.10.1", + "cookie-parser": "^1.4.7", + "helmet": "^8.3.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "sharp": "^0.35.5", + "zod": "^4.6.7" + }, + "devDependencies": { + "@nestjs/cli": "^12.0.10", + "@nestjs/schematics": "^12.0.10", + "@nestjs/testing": "^12.2.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.6", + "@types/multer": "^2.3.0", + "@types/node": "^24.0.0", + "@types/supertest": "^7.0.0", + "prisma": "7.10.1", + "supertest": "^7.1.4", + "tsx": "^4.20.6", + "typescript": "^6.0.2", + "vite": "^8.0.0", + "vitest": "^4.1.2" + } +} diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts new file mode 100644 index 0000000..67c3c51 --- /dev/null +++ b/apps/api/prisma.config.ts @@ -0,0 +1,17 @@ +import { existsSync } from "node:fs"; +import { defineConfig } from "prisma/config"; + +// Prisma 7 no longer loads .env on its own +if (existsSync(".env")) process.loadEnvFile(".env"); + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "tsx src/seed/seed.ts", + }, + datasource: { + // `prisma generate` (e.g. in the Docker build) does not need a real database + url: process.env.DATABASE_URL ?? "postgresql://placeholder:placeholder@localhost:5432/placeholder", + }, +}); diff --git a/apps/api/prisma/migrations/20261021130147_init/migration.sql b/apps/api/prisma/migrations/20261021130147_init/migration.sql new file mode 100644 index 0000000..a6590db --- /dev/null +++ b/apps/api/prisma/migrations/20261021130147_init/migration.sql @@ -0,0 +1,212 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'EDITOR'); + +-- CreateEnum +CREATE TYPE "MessageStatus" AS ENUM ('NEW', 'READ', 'ARCHIVED'); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'EDITOR', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "lastLoginAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "revokedAt" TIMESTAMP(3), + "userAgent" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Media" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "width" INTEGER, + "height" INTEGER, + "alt" TEXT NOT NULL DEFAULT '', + "originalName" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Media_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" SERIAL NOT NULL, + "slug" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEn" TEXT NOT NULL DEFAULT '', + "shortDesc" TEXT NOT NULL DEFAULT '', + "fullDesc" TEXT NOT NULL DEFAULT '', + "icon" TEXT NOT NULL DEFAULT '', + "coverImageId" INTEGER, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isPublished" BOOLEAN NOT NULL DEFAULT true, + "seoTitle" TEXT NOT NULL DEFAULT '', + "seoDescription" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductGroup" ( + "id" SERIAL NOT NULL, + "categoryId" INTEGER NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "desc" TEXT NOT NULL DEFAULT '', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" SERIAL NOT NULL, + "categoryId" INTEGER NOT NULL, + "groupId" INTEGER, + "code" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "nameEn" TEXT NOT NULL DEFAULT '', + "desc" TEXT NOT NULL DEFAULT '', + "specs" TEXT[], + "variants" TEXT[], + "imageId" INTEGER, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isPublished" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductImage" ( + "productId" INTEGER NOT NULL, + "mediaId" INTEGER NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("productId","mediaId") +); + +-- CreateTable +CREATE TABLE "SiteSection" ( + "key" TEXT NOT NULL, + "data" JSONB NOT NULL, + "updatedById" INTEGER, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteSection_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "ContactMessage" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "org" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "message" TEXT NOT NULL DEFAULT '', + "status" "MessageStatus" NOT NULL DEFAULT 'NEW', + "ip" TEXT NOT NULL DEFAULT '', + "userAgent" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" SERIAL NOT NULL, + "userId" INTEGER, + "action" TEXT NOT NULL, + "entity" TEXT NOT NULL, + "entityId" TEXT NOT NULL DEFAULT '', + "summary" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Media_key_key" ON "Media"("key"); + +-- CreateIndex +CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug"); + +-- CreateIndex +CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductGroup_categoryId_slug_key" ON "ProductGroup"("categoryId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Product_code_key" ON "Product"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug"); + +-- CreateIndex +CREATE INDEX "Product_categoryId_sortOrder_idx" ON "Product"("categoryId", "sortOrder"); + +-- CreateIndex +CREATE INDEX "ContactMessage_status_createdAt_idx" ON "ContactMessage"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- AddForeignKey +ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductGroup" ADD CONSTRAINT "ProductGroup_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProductGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..599fb6d --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,173 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" + moduleFormat = "esm" + importFileExtension = "js" +} + +datasource db { + provider = "postgresql" +} + +enum Role { + ADMIN + EDITOR +} + +enum MessageStatus { + NEW + READ + ARCHIVED +} + +model AdminUser { + id Int @id @default(autoincrement()) + email String @unique + name String + passwordHash String + role Role @default(EDITOR) + isActive Boolean @default(true) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + refreshTokens RefreshToken[] + auditLogs AuditLog[] +} + +model RefreshToken { + id Int @id @default(autoincrement()) + userId Int + user AdminUser @relation(fields: [userId], references: [id], onDelete: Cascade) + /// sha256 of the opaque token stored in the httpOnly cookie + tokenHash String @unique + expiresAt DateTime + revokedAt DateTime? + userAgent String @default("") + createdAt DateTime @default(now()) + + @@index([userId]) +} + +model Media { + id Int @id @default(autoincrement()) + /// object key in the S3 bucket; the public URL is MEDIA_PUBLIC_URL + "/" + key + key String @unique + mime String + size Int + width Int? + height Int? + alt String @default("") + originalName String @default("") + createdAt DateTime @default(now()) + categoryCovers Category[] @relation("CategoryCover") + productImages Product[] @relation("ProductImage") + productGallery ProductImage[] +} + +model Category { + id Int @id @default(autoincrement()) + slug String @unique + code String + name String + nameEn String @default("") + shortDesc String @default("") + fullDesc String @default("") + /// Lucide icon name + icon String @default("") + coverImageId Int? + coverImage Media? @relation("CategoryCover", fields: [coverImageId], references: [id], onDelete: SetNull) + sortOrder Int @default(0) + isPublished Boolean @default(true) + seoTitle String @default("") + seoDescription String @default("") + groups ProductGroup[] + products Product[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([sortOrder]) +} + +model ProductGroup { + id Int @id @default(autoincrement()) + categoryId Int + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + slug String + name String + desc String @default("") + sortOrder Int @default(0) + products Product[] + + @@unique([categoryId, slug]) +} + +model Product { + id Int @id @default(autoincrement()) + categoryId Int + category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) + groupId Int? + group ProductGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + code String @unique + slug String @unique + name String + nameEn String @default("") + desc String @default("") + specs String[] + variants String[] + imageId Int? + image Media? @relation("ProductImage", fields: [imageId], references: [id], onDelete: SetNull) + gallery ProductImage[] + sortOrder Int @default(0) + isPublished Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([categoryId, sortOrder]) +} + +model ProductImage { + productId Int + mediaId Int + sortOrder Int @default(0) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade) + + @@id([productId, mediaId]) +} + +/// One row per editable website section; `data` is validated by the zod schema for `key` (@imen/shared) +model SiteSection { + key String @id + data Json + updatedById Int? + updatedAt DateTime @updatedAt +} + +model ContactMessage { + id Int @id @default(autoincrement()) + name String + org String + phone String + message String @default("") + status MessageStatus @default(NEW) + ip String @default("") + userAgent String @default("") + createdAt DateTime @default(now()) + + @@index([status, createdAt]) +} + +model AuditLog { + id Int @id @default(autoincrement()) + userId Int? + user AdminUser? @relation(fields: [userId], references: [id], onDelete: SetNull) + /// create | update | delete | login ... + action String + /// product | category | section | media | user | message + entity String + entityId String @default("") + summary String @default("") + createdAt DateTime @default(now()) + + @@index([createdAt]) +} diff --git a/out/images/about.jpg b/apps/api/seed-assets/about.jpg similarity index 100% rename from out/images/about.jpg rename to apps/api/seed-assets/about.jpg diff --git a/out/images/hero.jpg b/apps/api/seed-assets/hero.jpg similarity index 100% rename from out/images/hero.jpg rename to apps/api/seed-assets/hero.jpg diff --git a/out/images/industries/construction.jpg b/apps/api/seed-assets/industries/construction.jpg similarity index 100% rename from out/images/industries/construction.jpg rename to apps/api/seed-assets/industries/construction.jpg diff --git a/out/images/industries/mining.jpg b/apps/api/seed-assets/industries/mining.jpg similarity index 100% rename from out/images/industries/mining.jpg rename to apps/api/seed-assets/industries/mining.jpg diff --git a/out/images/industries/oil-gas.jpg b/apps/api/seed-assets/industries/oil-gas.jpg similarity index 100% rename from out/images/industries/oil-gas.jpg rename to apps/api/seed-assets/industries/oil-gas.jpg diff --git a/out/images/industries/petrochemical.jpg b/apps/api/seed-assets/industries/petrochemical.jpg similarity index 100% rename from out/images/industries/petrochemical.jpg rename to apps/api/seed-assets/industries/petrochemical.jpg diff --git a/out/images/industries/power-energy.jpg b/apps/api/seed-assets/industries/power-energy.jpg similarity index 100% rename from out/images/industries/power-energy.jpg rename to apps/api/seed-assets/industries/power-energy.jpg diff --git a/out/images/industries/transport.jpg b/apps/api/seed-assets/industries/transport.jpg similarity index 100% rename from out/images/industries/transport.jpg rename to apps/api/seed-assets/industries/transport.jpg diff --git a/out/images/products/eyewear/1.webp b/apps/api/seed-assets/products/eyewear/1.webp similarity index 100% rename from out/images/products/eyewear/1.webp rename to apps/api/seed-assets/products/eyewear/1.webp diff --git a/out/images/products/eyewear/10.webp b/apps/api/seed-assets/products/eyewear/10.webp similarity index 100% rename from out/images/products/eyewear/10.webp rename to apps/api/seed-assets/products/eyewear/10.webp diff --git a/out/images/products/eyewear/11.webp b/apps/api/seed-assets/products/eyewear/11.webp similarity index 100% rename from out/images/products/eyewear/11.webp rename to apps/api/seed-assets/products/eyewear/11.webp diff --git a/out/images/products/eyewear/2.webp b/apps/api/seed-assets/products/eyewear/2.webp similarity index 100% rename from out/images/products/eyewear/2.webp rename to apps/api/seed-assets/products/eyewear/2.webp diff --git a/out/images/products/eyewear/3.webp b/apps/api/seed-assets/products/eyewear/3.webp similarity index 100% rename from out/images/products/eyewear/3.webp rename to apps/api/seed-assets/products/eyewear/3.webp diff --git a/out/images/products/eyewear/4.webp b/apps/api/seed-assets/products/eyewear/4.webp similarity index 100% rename from out/images/products/eyewear/4.webp rename to apps/api/seed-assets/products/eyewear/4.webp diff --git a/out/images/products/eyewear/5.webp b/apps/api/seed-assets/products/eyewear/5.webp similarity index 100% rename from out/images/products/eyewear/5.webp rename to apps/api/seed-assets/products/eyewear/5.webp diff --git a/out/images/products/eyewear/6.webp b/apps/api/seed-assets/products/eyewear/6.webp similarity index 100% rename from out/images/products/eyewear/6.webp rename to apps/api/seed-assets/products/eyewear/6.webp diff --git a/out/images/products/eyewear/7.webp b/apps/api/seed-assets/products/eyewear/7.webp similarity index 100% rename from out/images/products/eyewear/7.webp rename to apps/api/seed-assets/products/eyewear/7.webp diff --git a/out/images/products/eyewear/8.webp b/apps/api/seed-assets/products/eyewear/8.webp similarity index 100% rename from out/images/products/eyewear/8.webp rename to apps/api/seed-assets/products/eyewear/8.webp diff --git a/out/images/products/eyewear/9.webp b/apps/api/seed-assets/products/eyewear/9.webp similarity index 100% rename from out/images/products/eyewear/9.webp rename to apps/api/seed-assets/products/eyewear/9.webp diff --git a/out/images/products/gloves.jpg b/apps/api/seed-assets/products/gloves.jpg similarity index 100% rename from out/images/products/gloves.jpg rename to apps/api/seed-assets/products/gloves.jpg diff --git a/out/images/products/gloves/acid/1.webp b/apps/api/seed-assets/products/gloves/acid/1.webp similarity index 100% rename from out/images/products/gloves/acid/1.webp rename to apps/api/seed-assets/products/gloves/acid/1.webp diff --git a/out/images/products/gloves/acid/2.webp b/apps/api/seed-assets/products/gloves/acid/2.webp similarity index 100% rename from out/images/products/gloves/acid/2.webp rename to apps/api/seed-assets/products/gloves/acid/2.webp diff --git a/out/images/products/gloves/acid/3.webp b/apps/api/seed-assets/products/gloves/acid/3.webp similarity index 100% rename from out/images/products/gloves/acid/3.webp rename to apps/api/seed-assets/products/gloves/acid/3.webp diff --git a/out/images/products/gloves/acid/4.webp b/apps/api/seed-assets/products/gloves/acid/4.webp similarity index 100% rename from out/images/products/gloves/acid/4.webp rename to apps/api/seed-assets/products/gloves/acid/4.webp diff --git a/out/images/products/gloves/cut/1.webp b/apps/api/seed-assets/products/gloves/cut/1.webp similarity index 100% rename from out/images/products/gloves/cut/1.webp rename to apps/api/seed-assets/products/gloves/cut/1.webp diff --git a/out/images/products/gloves/cut/2.webp b/apps/api/seed-assets/products/gloves/cut/2.webp similarity index 100% rename from out/images/products/gloves/cut/2.webp rename to apps/api/seed-assets/products/gloves/cut/2.webp diff --git a/out/images/products/gloves/cut/3.webp b/apps/api/seed-assets/products/gloves/cut/3.webp similarity index 100% rename from out/images/products/gloves/cut/3.webp rename to apps/api/seed-assets/products/gloves/cut/3.webp diff --git a/out/images/products/gloves/cut/4.webp b/apps/api/seed-assets/products/gloves/cut/4.webp similarity index 100% rename from out/images/products/gloves/cut/4.webp rename to apps/api/seed-assets/products/gloves/cut/4.webp diff --git a/out/images/products/gloves/cut/5.webp b/apps/api/seed-assets/products/gloves/cut/5.webp similarity index 100% rename from out/images/products/gloves/cut/5.webp rename to apps/api/seed-assets/products/gloves/cut/5.webp diff --git a/out/images/products/gloves/leather/1.webp b/apps/api/seed-assets/products/gloves/leather/1.webp similarity index 100% rename from out/images/products/gloves/leather/1.webp rename to apps/api/seed-assets/products/gloves/leather/1.webp diff --git a/out/images/products/gloves/leather/2.webp b/apps/api/seed-assets/products/gloves/leather/2.webp similarity index 100% rename from out/images/products/gloves/leather/2.webp rename to apps/api/seed-assets/products/gloves/leather/2.webp diff --git a/out/images/products/gloves/leather/3.webp b/apps/api/seed-assets/products/gloves/leather/3.webp similarity index 100% rename from out/images/products/gloves/leather/3.webp rename to apps/api/seed-assets/products/gloves/leather/3.webp diff --git a/out/images/products/gloves/leather/4.webp b/apps/api/seed-assets/products/gloves/leather/4.webp similarity index 100% rename from out/images/products/gloves/leather/4.webp rename to apps/api/seed-assets/products/gloves/leather/4.webp diff --git a/out/images/products/gloves/leather/5.webp b/apps/api/seed-assets/products/gloves/leather/5.webp similarity index 100% rename from out/images/products/gloves/leather/5.webp rename to apps/api/seed-assets/products/gloves/leather/5.webp diff --git a/out/images/products/gloves/leather/6.webp b/apps/api/seed-assets/products/gloves/leather/6.webp similarity index 100% rename from out/images/products/gloves/leather/6.webp rename to apps/api/seed-assets/products/gloves/leather/6.webp diff --git a/out/images/products/gloves/solvent/1.webp b/apps/api/seed-assets/products/gloves/solvent/1.webp similarity index 100% rename from out/images/products/gloves/solvent/1.webp rename to apps/api/seed-assets/products/gloves/solvent/1.webp diff --git a/out/images/products/gloves/solvent/2.webp b/apps/api/seed-assets/products/gloves/solvent/2.webp similarity index 100% rename from out/images/products/gloves/solvent/2.webp rename to apps/api/seed-assets/products/gloves/solvent/2.webp diff --git a/out/images/products/gloves/solvent/3.webp b/apps/api/seed-assets/products/gloves/solvent/3.webp similarity index 100% rename from out/images/products/gloves/solvent/3.webp rename to apps/api/seed-assets/products/gloves/solvent/3.webp diff --git a/out/images/products/gloves/solvent/4.webp b/apps/api/seed-assets/products/gloves/solvent/4.webp similarity index 100% rename from out/images/products/gloves/solvent/4.webp rename to apps/api/seed-assets/products/gloves/solvent/4.webp diff --git a/out/images/products/goggles.jpg b/apps/api/seed-assets/products/goggles.jpg similarity index 100% rename from out/images/products/goggles.jpg rename to apps/api/seed-assets/products/goggles.jpg diff --git a/out/images/products/harness.jpg b/apps/api/seed-assets/products/harness.jpg similarity index 100% rename from out/images/products/harness.jpg rename to apps/api/seed-assets/products/harness.jpg diff --git a/out/images/products/hearing.jpg b/apps/api/seed-assets/products/hearing.jpg similarity index 100% rename from out/images/products/hearing.jpg rename to apps/api/seed-assets/products/hearing.jpg diff --git a/out/images/products/hearing/1.webp b/apps/api/seed-assets/products/hearing/1.webp similarity index 100% rename from out/images/products/hearing/1.webp rename to apps/api/seed-assets/products/hearing/1.webp diff --git a/out/images/products/hearing/2.webp b/apps/api/seed-assets/products/hearing/2.webp similarity index 100% rename from out/images/products/hearing/2.webp rename to apps/api/seed-assets/products/hearing/2.webp diff --git a/out/images/products/hearing/3.webp b/apps/api/seed-assets/products/hearing/3.webp similarity index 100% rename from out/images/products/hearing/3.webp rename to apps/api/seed-assets/products/hearing/3.webp diff --git a/out/images/products/hearing/4.webp b/apps/api/seed-assets/products/hearing/4.webp similarity index 100% rename from out/images/products/hearing/4.webp rename to apps/api/seed-assets/products/hearing/4.webp diff --git a/out/images/products/hearing/5.webp b/apps/api/seed-assets/products/hearing/5.webp similarity index 100% rename from out/images/products/hearing/5.webp rename to apps/api/seed-assets/products/hearing/5.webp diff --git a/out/images/products/hearing/6.webp b/apps/api/seed-assets/products/hearing/6.webp similarity index 100% rename from out/images/products/hearing/6.webp rename to apps/api/seed-assets/products/hearing/6.webp diff --git a/out/images/products/hearing/7.webp b/apps/api/seed-assets/products/hearing/7.webp similarity index 100% rename from out/images/products/hearing/7.webp rename to apps/api/seed-assets/products/hearing/7.webp diff --git a/out/images/products/hearing/8.webp b/apps/api/seed-assets/products/hearing/8.webp similarity index 100% rename from out/images/products/hearing/8.webp rename to apps/api/seed-assets/products/hearing/8.webp diff --git a/out/images/products/helmet.jpg b/apps/api/seed-assets/products/helmet.jpg similarity index 100% rename from out/images/products/helmet.jpg rename to apps/api/seed-assets/products/helmet.jpg diff --git a/out/images/products/helmets/1.webp b/apps/api/seed-assets/products/helmets/1.webp similarity index 100% rename from out/images/products/helmets/1.webp rename to apps/api/seed-assets/products/helmets/1.webp diff --git a/out/images/products/helmets/10.webp b/apps/api/seed-assets/products/helmets/10.webp similarity index 100% rename from out/images/products/helmets/10.webp rename to apps/api/seed-assets/products/helmets/10.webp diff --git a/out/images/products/helmets/11.webp b/apps/api/seed-assets/products/helmets/11.webp similarity index 100% rename from out/images/products/helmets/11.webp rename to apps/api/seed-assets/products/helmets/11.webp diff --git a/out/images/products/helmets/12.webp b/apps/api/seed-assets/products/helmets/12.webp similarity index 100% rename from out/images/products/helmets/12.webp rename to apps/api/seed-assets/products/helmets/12.webp diff --git a/out/images/products/helmets/2.webp b/apps/api/seed-assets/products/helmets/2.webp similarity index 100% rename from out/images/products/helmets/2.webp rename to apps/api/seed-assets/products/helmets/2.webp diff --git a/out/images/products/helmets/3.webp b/apps/api/seed-assets/products/helmets/3.webp similarity index 100% rename from out/images/products/helmets/3.webp rename to apps/api/seed-assets/products/helmets/3.webp diff --git a/out/images/products/helmets/4.webp b/apps/api/seed-assets/products/helmets/4.webp similarity index 100% rename from out/images/products/helmets/4.webp rename to apps/api/seed-assets/products/helmets/4.webp diff --git a/out/images/products/helmets/5.webp b/apps/api/seed-assets/products/helmets/5.webp similarity index 100% rename from out/images/products/helmets/5.webp rename to apps/api/seed-assets/products/helmets/5.webp diff --git a/out/images/products/helmets/9.webp b/apps/api/seed-assets/products/helmets/9.webp similarity index 100% rename from out/images/products/helmets/9.webp rename to apps/api/seed-assets/products/helmets/9.webp diff --git a/out/images/products/respiratory.jpg b/apps/api/seed-assets/products/respiratory.jpg similarity index 100% rename from out/images/products/respiratory.jpg rename to apps/api/seed-assets/products/respiratory.jpg diff --git a/out/images/products/respiratory/1.webp b/apps/api/seed-assets/products/respiratory/1.webp similarity index 100% rename from out/images/products/respiratory/1.webp rename to apps/api/seed-assets/products/respiratory/1.webp diff --git a/out/images/products/respiratory/10.webp b/apps/api/seed-assets/products/respiratory/10.webp similarity index 100% rename from out/images/products/respiratory/10.webp rename to apps/api/seed-assets/products/respiratory/10.webp diff --git a/out/images/products/respiratory/11.webp b/apps/api/seed-assets/products/respiratory/11.webp similarity index 100% rename from out/images/products/respiratory/11.webp rename to apps/api/seed-assets/products/respiratory/11.webp diff --git a/out/images/products/respiratory/2.webp b/apps/api/seed-assets/products/respiratory/2.webp similarity index 100% rename from out/images/products/respiratory/2.webp rename to apps/api/seed-assets/products/respiratory/2.webp diff --git a/out/images/products/respiratory/3.webp b/apps/api/seed-assets/products/respiratory/3.webp similarity index 100% rename from out/images/products/respiratory/3.webp rename to apps/api/seed-assets/products/respiratory/3.webp diff --git a/out/images/products/respiratory/4.webp b/apps/api/seed-assets/products/respiratory/4.webp similarity index 100% rename from out/images/products/respiratory/4.webp rename to apps/api/seed-assets/products/respiratory/4.webp diff --git a/out/images/products/respiratory/5.webp b/apps/api/seed-assets/products/respiratory/5.webp similarity index 100% rename from out/images/products/respiratory/5.webp rename to apps/api/seed-assets/products/respiratory/5.webp diff --git a/out/images/products/respiratory/6.webp b/apps/api/seed-assets/products/respiratory/6.webp similarity index 100% rename from out/images/products/respiratory/6.webp rename to apps/api/seed-assets/products/respiratory/6.webp diff --git a/out/images/products/respiratory/7.webp b/apps/api/seed-assets/products/respiratory/7.webp similarity index 100% rename from out/images/products/respiratory/7.webp rename to apps/api/seed-assets/products/respiratory/7.webp diff --git a/out/images/products/respiratory/8.webp b/apps/api/seed-assets/products/respiratory/8.webp similarity index 100% rename from out/images/products/respiratory/8.webp rename to apps/api/seed-assets/products/respiratory/8.webp diff --git a/out/images/products/respiratory/9.webp b/apps/api/seed-assets/products/respiratory/9.webp similarity index 100% rename from out/images/products/respiratory/9.webp rename to apps/api/seed-assets/products/respiratory/9.webp diff --git a/out/images/products/shoes.jpg b/apps/api/seed-assets/products/shoes.jpg similarity index 100% rename from out/images/products/shoes.jpg rename to apps/api/seed-assets/products/shoes.jpg diff --git a/out/images/products/shoes/office/1.webp b/apps/api/seed-assets/products/shoes/office/1.webp similarity index 100% rename from out/images/products/shoes/office/1.webp rename to apps/api/seed-assets/products/shoes/office/1.webp diff --git a/out/images/products/shoes/office/2.webp b/apps/api/seed-assets/products/shoes/office/2.webp similarity index 100% rename from out/images/products/shoes/office/2.webp rename to apps/api/seed-assets/products/shoes/office/2.webp diff --git a/out/images/products/shoes/office/3.webp b/apps/api/seed-assets/products/shoes/office/3.webp similarity index 100% rename from out/images/products/shoes/office/3.webp rename to apps/api/seed-assets/products/shoes/office/3.webp diff --git a/out/images/products/shoes/office/4.webp b/apps/api/seed-assets/products/shoes/office/4.webp similarity index 100% rename from out/images/products/shoes/office/4.webp rename to apps/api/seed-assets/products/shoes/office/4.webp diff --git a/out/images/products/shoes/office/5.webp b/apps/api/seed-assets/products/shoes/office/5.webp similarity index 100% rename from out/images/products/shoes/office/5.webp rename to apps/api/seed-assets/products/shoes/office/5.webp diff --git a/out/images/products/shoes/office/6.webp b/apps/api/seed-assets/products/shoes/office/6.webp similarity index 100% rename from out/images/products/shoes/office/6.webp rename to apps/api/seed-assets/products/shoes/office/6.webp diff --git a/out/images/products/shoes/office/7.webp b/apps/api/seed-assets/products/shoes/office/7.webp similarity index 100% rename from out/images/products/shoes/office/7.webp rename to apps/api/seed-assets/products/shoes/office/7.webp diff --git a/out/images/products/shoes/office/8.webp b/apps/api/seed-assets/products/shoes/office/8.webp similarity index 100% rename from out/images/products/shoes/office/8.webp rename to apps/api/seed-assets/products/shoes/office/8.webp diff --git a/out/images/products/shoes/worker/1.png b/apps/api/seed-assets/products/shoes/worker/1.png similarity index 100% rename from out/images/products/shoes/worker/1.png rename to apps/api/seed-assets/products/shoes/worker/1.png diff --git a/out/images/products/shoes/worker/2.png b/apps/api/seed-assets/products/shoes/worker/2.png similarity index 100% rename from out/images/products/shoes/worker/2.png rename to apps/api/seed-assets/products/shoes/worker/2.png diff --git a/out/images/products/shoes/worker/3.png b/apps/api/seed-assets/products/shoes/worker/3.png similarity index 100% rename from out/images/products/shoes/worker/3.png rename to apps/api/seed-assets/products/shoes/worker/3.png diff --git a/out/images/products/shoes/worker/4.png b/apps/api/seed-assets/products/shoes/worker/4.png similarity index 100% rename from out/images/products/shoes/worker/4.png rename to apps/api/seed-assets/products/shoes/worker/4.png diff --git a/out/images/products/shoes/worker/5.png b/apps/api/seed-assets/products/shoes/worker/5.png similarity index 100% rename from out/images/products/shoes/worker/5.png rename to apps/api/seed-assets/products/shoes/worker/5.png diff --git a/out/images/products/shoes/worker/6.png b/apps/api/seed-assets/products/shoes/worker/6.png similarity index 100% rename from out/images/products/shoes/worker/6.png rename to apps/api/seed-assets/products/shoes/worker/6.png diff --git a/out/images/products/traffic.jpg b/apps/api/seed-assets/products/traffic.jpg similarity index 100% rename from out/images/products/traffic.jpg rename to apps/api/seed-assets/products/traffic.jpg diff --git a/out/images/products/vest.jpg b/apps/api/seed-assets/products/vest.jpg similarity index 100% rename from out/images/products/vest.jpg rename to apps/api/seed-assets/products/vest.jpg diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..b0c7ea7 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Module } from "@nestjs/common"; +import { APP_FILTER, APP_GUARD } from "@nestjs/core"; +import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; +import { AuthModule } from "./auth/auth.controller.js"; +import { CatalogModule } from "./catalog/catalog.module.js"; +import { CommonModule } from "./common/common.module.js"; +import { PrismaExceptionFilter } from "./common/prisma-exception.filter.js"; +import { ContactModule } from "./contact/contact.module.js"; +import { DashboardModule } from "./dashboard/dashboard.module.js"; +import { MediaModule } from "./media/media.controller.js"; +import { PrismaModule, PrismaService } from "./prisma/prisma.service.js"; +import { PublicModule } from "./public/public.module.js"; +import { SectionsModule } from "./sections/sections.module.js"; +import { UsersModule } from "./users/users.controller.js"; + +@Controller("health") +class HealthController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + async health() { + await this.prisma.$queryRaw`SELECT 1`; + return { ok: true }; + } +} + +@Module({ + imports: [ + // generous default; sensitive routes (login, contact form) set stricter limits + ThrottlerModule.forRoot([{ ttl: 60_000, limit: 300 }]), + PrismaModule, + CommonModule, + AuthModule, + UsersModule, + MediaModule, + CatalogModule, + SectionsModule, + ContactModule, + PublicModule, + DashboardModule, + ], + controllers: [HealthController], + providers: [ + { provide: APP_GUARD, useClass: ThrottlerGuard }, + { provide: APP_FILTER, useClass: PrismaExceptionFilter }, + ], +}) +export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..083b48e --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,101 @@ +import { Body, Controller, Get, HttpCode, Module, Post, Req, Res } from "@nestjs/common"; +import { JwtModule, type JwtSignOptions } from "@nestjs/jwt"; +import { Throttle } from "@nestjs/throttler"; +import { + type AuthResponse, + type ChangePasswordInput, + changePasswordSchema, + type LoginInput, + loginSchema, +} from "@imen/shared"; +import type { Request, Response } from "express"; +import { env, isProd } from "../config/env.js"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { ZodPipe } from "../common/zod.pipe.js"; +import { AuthService, type IssuedTokens } from "./auth.service.js"; + +const COOKIE = "imen_rt"; +const COOKIE_PATH = "/api/admin/auth"; + +const ua = (req: Request) => req.headers["user-agent"] ?? ""; +const readCookie = (req: Request): string | undefined => + (req.cookies as Record | undefined)?.[COOKIE]; + +@Controller("admin/auth") +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Post("login") + @HttpCode(200) + @Throttle({ default: { limit: 8, ttl: 60_000 } }) + async login( + @Body(new ZodPipe(loginSchema)) body: LoginInput, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + return this.respond(res, await this.auth.login(body, ua(req))); + } + + @Post("refresh") + @HttpCode(200) + @Throttle({ default: { limit: 30, ttl: 60_000 } }) + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + try { + return this.respond(res, await this.auth.refresh(readCookie(req), ua(req))); + } catch (e) { + res.clearCookie(COOKIE, { path: COOKIE_PATH }); + throw e; + } + } + + @Post("logout") + @HttpCode(204) + async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + await this.auth.logout(readCookie(req)); + res.clearCookie(COOKIE, { path: COOKIE_PATH }); + } + + @Get("me") + @AdminAuth() + me(@CurrentUser() user: AuthUser) { + return this.auth.me(user.id); + } + + @Post("change-password") + @HttpCode(204) + @AdminAuth() + async changePassword( + @Body(new ZodPipe(changePasswordSchema)) body: ChangePasswordInput, + @CurrentUser() user: AuthUser, + ) { + await this.auth.changePassword(user.id, body); + } + + private respond(res: Response, t: IssuedTokens): AuthResponse { + res.cookie(COOKIE, t.refreshToken, { + httpOnly: true, + secure: isProd && env.COOKIE_SECURE, + sameSite: "strict", + path: COOKIE_PATH, + expires: t.refreshExpiresAt, + }); + return { accessToken: t.accessToken, user: t.user }; + } +} + +@Module({ + imports: [ + JwtModule.register({ + global: true, + secret: env.JWT_SECRET, + signOptions: { expiresIn: env.JWT_ACCESS_TTL as JwtSignOptions["expiresIn"] }, + }), + ], + controllers: [AuthController], + providers: [AuthService], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..d37a16a --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,139 @@ +import { createHash, randomBytes } from "node:crypto"; +import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { hash, verify } from "@node-rs/argon2"; +import type { AdminUserDto, AuthResponse, ChangePasswordInput, LoginInput } from "@imen/shared"; +import { env } from "../config/env.js"; +import type { JwtPayload } from "../common/auth.js"; +import { AuditService } from "../common/common.module.js"; +import type { AdminUser } from "../generated/prisma/client.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +export const hashPassword = (password: string) => hash(password); + +const sha256 = (v: string) => createHash("sha256").update(v).digest("hex"); + +export const toUserDto = (u: AdminUser): AdminUserDto => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + isActive: u.isActive, + lastLoginAt: u.lastLoginAt?.toISOString() ?? null, + createdAt: u.createdAt.toISOString(), +}); + +export interface IssuedTokens extends AuthResponse { + refreshToken: string; + refreshExpiresAt: Date; +} + +const INVALID_LOGIN = "ایمیل یا رمز عبور اشتباه است"; + +/** hash of a random password, verified against when the email is unknown so timing stays the same */ +const DUMMY_HASH = hashPassword(randomBytes(16).toString("hex")); + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly audit: AuditService, + ) {} + + async login(input: LoginInput, userAgent: string): Promise { + const user = await this.prisma.adminUser.findUnique({ where: { email: input.email } }); + // verify even when the user is missing so response time does not reveal valid emails + const ok = await verify(user?.passwordHash ?? (await DUMMY_HASH), input.password).catch( + () => false, + ); + if (!user || !ok || !user.isActive) throw new UnauthorizedException(INVALID_LOGIN); + + const updated = await this.prisma.adminUser.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + this.audit.log(user.id, "login", "user", user.id, user.email); + return this.issue(updated, userAgent); + } + + /** Rotates the refresh token: the old one is revoked and a new pair is issued */ + async refresh(token: string | undefined, userAgent: string): Promise { + if (!token) throw new UnauthorizedException("نشست شما منقضی شده است"); + + const stored = await this.prisma.refreshToken.findUnique({ + where: { tokenHash: sha256(token) }, + include: { user: true }, + }); + if (!stored) throw new UnauthorizedException("نشست شما منقضی شده است"); + + if (stored.revokedAt) { + // a revoked token was replayed: assume it leaked and end every session of this user + await this.revokeAll(stored.userId); + throw new UnauthorizedException("نشست شما منقضی شده است"); + } + if (stored.expiresAt < new Date() || !stored.user.isActive) { + throw new UnauthorizedException("نشست شما منقضی شده است"); + } + + await this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { revokedAt: new Date() }, + }); + return this.issue(stored.user, userAgent); + } + + async logout(token: string | undefined) { + if (!token) return; + await this.prisma.refreshToken.updateMany({ + where: { tokenHash: sha256(token), revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async changePassword(userId: number, input: ChangePasswordInput) { + const user = await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } }); + if (!(await verify(user.passwordHash, input.currentPassword).catch(() => false))) { + throw new BadRequestException("رمز عبور فعلی اشتباه است"); + } + await this.prisma.adminUser.update({ + where: { id: userId }, + data: { passwordHash: await hashPassword(input.newPassword) }, + }); + this.audit.log(userId, "change-password", "user", userId, user.email); + } + + async me(userId: number) { + return toUserDto(await this.prisma.adminUser.findUniqueOrThrow({ where: { id: userId } })); + } + + revokeAll(userId: number) { + return this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + private async issue(user: AdminUser, userAgent: string): Promise { + const payload: JwtPayload = { sub: user.id, role: user.role }; + const accessToken = await this.jwt.signAsync(payload); + + const refreshToken = randomBytes(48).toString("base64url"); + const refreshExpiresAt = new Date(Date.now() + env.REFRESH_TTL_DAYS * 86_400_000); + await this.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: sha256(refreshToken), + expiresAt: refreshExpiresAt, + userAgent: userAgent.slice(0, 300), + }, + }); + + // keep the table small: drop tokens that expired more than a day ago + void this.prisma.refreshToken + .deleteMany({ where: { expiresAt: { lt: new Date(Date.now() - 86_400_000) } } }) + .catch(() => undefined); + + return { accessToken, refreshToken, refreshExpiresAt, user: toUserDto(user) }; + } +} diff --git a/apps/api/src/catalog/catalog.module.ts b/apps/api/src/catalog/catalog.module.ts new file mode 100644 index 0000000..7cab823 --- /dev/null +++ b/apps/api/src/catalog/catalog.module.ts @@ -0,0 +1,139 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Module, + Param, + Patch, + Post, + Put, + Query, +} from "@nestjs/common"; +import { + type CategoryInput, + categoryInputSchema, + type ProductInput, + productInputSchema, + type ProductListQuery, + productListQuerySchema, + type ReorderInput, + reorderSchema, +} from "@imen/shared"; +import { z } from "zod"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { IdPipe, ZodPipe } from "../common/zod.pipe.js"; +import { MediaModule } from "../media/media.controller.js"; +import { CategoriesService } from "./categories.service.js"; +import { ProductsService } from "./products.service.js"; + +const publishSchema = z.object({ isPublished: z.boolean() }); + +@AdminAuth() +@Controller("admin/categories") +export class CategoriesController { + constructor(private readonly categories: CategoriesService) {} + + @Get() + list() { + return this.categories.list(); + } + + @Get(":id") + get(@Param("id", IdPipe) id: number) { + return this.categories.get(id); + } + + @Post() + create( + @Body(new ZodPipe(categoryInputSchema)) body: CategoryInput, + @CurrentUser() user: AuthUser, + ) { + return this.categories.create(body, user.id); + } + + @Post("reorder") + @HttpCode(204) + async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) { + await this.categories.reorder(body.ids, user.id); + } + + @Put(":id") + update( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(categoryInputSchema)) body: CategoryInput, + @CurrentUser() user: AuthUser, + ) { + return this.categories.update(id, body, user.id); + } + + @Delete(":id") + @HttpCode(204) + async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { + await this.categories.remove(id, user.id); + } +} + +@AdminAuth() +@Controller("admin/products") +export class ProductsController { + constructor(private readonly products: ProductsService) {} + + @Get() + list(@Query(new ZodPipe(productListQuerySchema)) q: ProductListQuery) { + return this.products.list(q); + } + + @Get(":id") + get(@Param("id", IdPipe) id: number) { + return this.products.get(id); + } + + @Post() + create(@Body(new ZodPipe(productInputSchema)) body: ProductInput, @CurrentUser() user: AuthUser) { + return this.products.create(body, user.id); + } + + @Post("reorder") + @HttpCode(204) + async reorder(@Body(new ZodPipe(reorderSchema)) body: ReorderInput, @CurrentUser() user: AuthUser) { + await this.products.reorder(body.ids, user.id); + } + + @Post(":id/duplicate") + duplicate(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { + return this.products.duplicate(id, user.id); + } + + @Patch(":id/publish") + publish( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(publishSchema)) body: z.output, + @CurrentUser() user: AuthUser, + ) { + return this.products.setPublished(id, body.isPublished, user.id); + } + + @Put(":id") + update( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(productInputSchema)) body: ProductInput, + @CurrentUser() user: AuthUser, + ) { + return this.products.update(id, body, user.id); + } + + @Delete(":id") + @HttpCode(204) + async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { + await this.products.remove(id, user.id); + } +} + +@Module({ + imports: [MediaModule], + controllers: [CategoriesController, ProductsController], + providers: [CategoriesService, ProductsService], +}) +export class CatalogModule {} diff --git a/apps/api/src/catalog/categories.service.ts b/apps/api/src/catalog/categories.service.ts new file mode 100644 index 0000000..095e163 --- /dev/null +++ b/apps/api/src/catalog/categories.service.ts @@ -0,0 +1,132 @@ +import { ConflictException, Injectable } from "@nestjs/common"; +import type { CategoryInput } from "@imen/shared"; +import { AuditService, RevalidateService } from "../common/common.module.js"; +import { MediaService } from "../media/media.service.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +const withCover = { coverImage: true } as const; + +@Injectable() +export class CategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly media: MediaService, + private readonly audit: AuditService, + private readonly revalidate: RevalidateService, + ) {} + + async list() { + const rows = await this.prisma.category.findMany({ + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + include: { ...withCover, _count: { select: { products: true, groups: true } } }, + }); + return rows.map(({ coverImage, _count, ...c }) => ({ + ...c, + coverImage: coverImage && this.media.toDto(coverImage), + productCount: _count.products, + groupCount: _count.groups, + })); + } + + async get(id: number) { + const { coverImage, ...c } = await this.prisma.category.findUniqueOrThrow({ + where: { id }, + include: { + ...withCover, + groups: { + orderBy: { sortOrder: "asc" }, + include: { _count: { select: { products: true } } }, + }, + }, + }); + return { + ...c, + coverImage: coverImage && this.media.toDto(coverImage), + groups: c.groups.map(({ _count, ...g }) => ({ ...g, productCount: _count.products })), + }; + } + + async create(input: CategoryInput, userId: number) { + this.assertUniqueGroupSlugs(input); + const { groups, ...data } = input; + const last = await this.prisma.category.aggregate({ _max: { sortOrder: true } }); + + const category = await this.prisma.category.create({ + data: { + ...data, + sortOrder: (last._max.sortOrder ?? -1) + 1, + groups: { + create: groups.map((g, i) => ({ slug: g.slug, name: g.name, desc: g.desc, sortOrder: i })), + }, + }, + }); + this.audit.log(userId, "create", "category", category.id, category.name); + this.revalidate.trigger(); + return this.get(category.id); + } + + async update(id: number, input: CategoryInput, userId: number) { + this.assertUniqueGroupSlugs(input); + const { groups, ...data } = input; + + await this.prisma.$transaction(async (tx) => { + const category = await tx.category.update({ where: { id }, data }); + + // sync groups: keep/update listed ids, delete missing ones, create new ones + const keepIds = groups.flatMap((g) => (g.id ? [g.id] : [])); + await tx.productGroup.deleteMany({ where: { categoryId: id, id: { notIn: keepIds } } }); + + // temporary slugs avoid unique clashes when two groups swap slugs + for (const g of groups) { + if (g.id) { + await tx.productGroup.update({ + where: { id: g.id, categoryId: id }, + data: { slug: `__tmp-${g.id}` }, + }); + } + } + for (const [i, g] of groups.entries()) { + const fields = { slug: g.slug, name: g.name, desc: g.desc, sortOrder: i }; + if (g.id) { + await tx.productGroup.update({ where: { id: g.id, categoryId: id }, data: fields }); + } else { + await tx.productGroup.create({ data: { ...fields, categoryId: id } }); + } + } + this.audit.log(userId, "update", "category", id, category.name); + }); + + this.revalidate.trigger(); + return this.get(id); + } + + async remove(id: number, userId: number) { + const category = await this.prisma.category.findUniqueOrThrow({ + where: { id }, + include: { _count: { select: { products: true } } }, + }); + if (category._count.products > 0) { + throw new ConflictException( + `این دسته ${category._count.products} محصول دارد؛ ابتدا محصولات را حذف یا جابه‌جا کنید`, + ); + } + await this.prisma.category.delete({ where: { id } }); + this.audit.log(userId, "delete", "category", id, category.name); + this.revalidate.trigger(); + } + + async reorder(ids: number[], userId: number) { + await this.prisma.$transaction( + ids.map((id, i) => this.prisma.category.update({ where: { id }, data: { sortOrder: i } })), + ); + this.audit.log(userId, "reorder", "category", "", `${ids.length} دسته`); + this.revalidate.trigger(); + } + + private assertUniqueGroupSlugs(input: CategoryInput) { + const slugs = input.groups.map((g) => g.slug); + if (new Set(slugs).size !== slugs.length) { + throw new ConflictException("اسلاگ زیرگروه‌ها باید یکتا باشد"); + } + } +} diff --git a/apps/api/src/catalog/products.service.ts b/apps/api/src/catalog/products.service.ts new file mode 100644 index 0000000..ca3fca7 --- /dev/null +++ b/apps/api/src/catalog/products.service.ts @@ -0,0 +1,165 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import type { ProductInput, ProductListQuery } from "@imen/shared"; +import { AuditService, RevalidateService } from "../common/common.module.js"; +import type { Prisma } from "../generated/prisma/client.js"; +import { MediaService } from "../media/media.service.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +@Injectable() +export class ProductsService { + constructor( + private readonly prisma: PrismaService, + private readonly media: MediaService, + private readonly audit: AuditService, + private readonly revalidate: RevalidateService, + ) {} + + async list(q: ProductListQuery) { + const where: Prisma.ProductWhereInput = { + ...(q.categoryId && { categoryId: q.categoryId }), + ...(q.groupId && { groupId: q.groupId }), + ...(q.status && { isPublished: q.status === "published" }), + ...(q.q && { + OR: [ + { name: { contains: q.q, mode: "insensitive" } }, + { nameEn: { contains: q.q, mode: "insensitive" } }, + { code: { contains: q.q, mode: "insensitive" } }, + ], + }), + }; + const [rows, total] = await this.prisma.$transaction([ + this.prisma.product.findMany({ + where, + orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }, { id: "asc" }], + skip: (q.page - 1) * q.pageSize, + take: q.pageSize, + include: { + image: true, + category: { select: { id: true, name: true } }, + group: { select: { id: true, name: true } }, + }, + }), + this.prisma.product.count({ where }), + ]); + return { + items: rows.map(({ image, ...p }) => ({ ...p, image: image && this.media.toDto(image) })), + total, + page: q.page, + pageSize: q.pageSize, + }; + } + + async get(id: number) { + const { image, gallery, ...p } = await this.prisma.product.findUniqueOrThrow({ + where: { id }, + include: { image: true, gallery: { orderBy: { sortOrder: "asc" }, include: { media: true } } }, + }); + return { + ...p, + image: image && this.media.toDto(image), + gallery: gallery.map((g) => this.media.toDto(g.media)), + }; + } + + async create(input: ProductInput, userId: number) { + await this.assertGroup(input); + const { galleryIds, ...data } = input; + const last = await this.prisma.product.aggregate({ + where: { categoryId: input.categoryId }, + _max: { sortOrder: true }, + }); + const product = await this.prisma.product.create({ + data: { + ...data, + sortOrder: (last._max.sortOrder ?? -1) + 1, + gallery: { create: unique(galleryIds).map((mediaId, i) => ({ mediaId, sortOrder: i })) }, + }, + }); + this.audit.log(userId, "create", "product", product.id, product.name); + this.revalidate.trigger(); + return this.get(product.id); + } + + async update(id: number, input: ProductInput, userId: number) { + await this.assertGroup(input); + const { galleryIds, ...data } = input; + const product = await this.prisma.product.update({ + where: { id }, + data: { + ...data, + gallery: { + deleteMany: {}, + create: unique(galleryIds).map((mediaId, i) => ({ mediaId, sortOrder: i })), + }, + }, + }); + this.audit.log(userId, "update", "product", id, product.name); + this.revalidate.trigger(); + return this.get(id); + } + + async setPublished(id: number, isPublished: boolean, userId: number) { + const product = await this.prisma.product.update({ where: { id }, data: { isPublished } }); + this.audit.log(userId, isPublished ? "publish" : "unpublish", "product", id, product.name); + this.revalidate.trigger(); + return { id, isPublished }; + } + + /** Copies a product as an unpublished draft with unique code/slug */ + async duplicate(id: number, userId: number) { + const src = await this.prisma.product.findUniqueOrThrow({ + where: { id }, + include: { gallery: true }, + }); + let n = 1; + const taken = async (code: string, slug: string) => + (await this.prisma.product.count({ where: { OR: [{ code }, { slug }] } })) > 0; + while (await taken(`${src.code}-${n}`, `${src.slug}-copy-${n}`)) n++; + + const copy = await this.prisma.product.create({ + data: { + categoryId: src.categoryId, + groupId: src.groupId, + code: `${src.code}-${n}`, + slug: `${src.slug}-copy-${n}`, + name: `${src.name} (کپی)`, + nameEn: src.nameEn, + desc: src.desc, + specs: src.specs, + variants: src.variants, + imageId: src.imageId, + sortOrder: src.sortOrder + 1, + isPublished: false, + gallery: { + create: src.gallery.map((g) => ({ mediaId: g.mediaId, sortOrder: g.sortOrder })), + }, + }, + }); + this.audit.log(userId, "duplicate", "product", copy.id, copy.name); + return this.get(copy.id); + } + + async remove(id: number, userId: number) { + const product = await this.prisma.product.delete({ where: { id } }); + this.audit.log(userId, "delete", "product", id, product.name); + this.revalidate.trigger(); + } + + async reorder(ids: number[], userId: number) { + await this.prisma.$transaction( + ids.map((id, i) => this.prisma.product.update({ where: { id }, data: { sortOrder: i } })), + ); + this.audit.log(userId, "reorder", "product", "", `${ids.length} محصول`); + this.revalidate.trigger(); + } + + private async assertGroup(input: ProductInput) { + if (!input.groupId) return; + const group = await this.prisma.productGroup.findUnique({ where: { id: input.groupId } }); + if (group?.categoryId !== input.categoryId) { + throw new BadRequestException("زیرگروه انتخاب‌شده متعلق به این دسته نیست"); + } + } +} + +const unique = (ids: number[]) => [...new Set(ids)]; diff --git a/apps/api/src/common/auth.ts b/apps/api/src/common/auth.ts new file mode 100644 index 0000000..0c6a31b --- /dev/null +++ b/apps/api/src/common/auth.ts @@ -0,0 +1,80 @@ +import { + type CanActivate, + createParamDecorator, + type ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, + UnauthorizedException, + UseGuards, + applyDecorators, +} from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { JwtService } from "@nestjs/jwt"; +import type { Role } from "@imen/shared"; +import type { Request } from "express"; +import { PrismaService } from "../prisma/prisma.service.js"; + +export interface JwtPayload { + sub: number; + role: Role; +} + +export interface AuthUser { + id: number; + email: string; + name: string; + role: Role; +} + +type AuthedRequest = Request & { user?: AuthUser }; + +const ROLES_KEY = "roles"; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwt: JwtService, + private readonly reflector: Reflector, + private readonly prisma: PrismaService, + ) {} + + async canActivate(ctx: ExecutionContext): Promise { + const req = ctx.switchToHttp().getRequest(); + const [scheme, token] = (req.headers.authorization ?? "").split(" "); + if (scheme !== "Bearer" || !token) throw new UnauthorizedException("ابتدا وارد شوید"); + + let payload: JwtPayload; + try { + payload = await this.jwt.verifyAsync(token); + } catch { + throw new UnauthorizedException("نشست شما منقضی شده است"); + } + + // re-check the user so deactivation and role changes apply immediately + const user = await this.prisma.adminUser.findUnique({ where: { id: payload.sub } }); + if (!user?.isActive) throw new UnauthorizedException("حساب کاربری غیرفعال است"); + req.user = { id: user.id, email: user.email, name: user.name, role: user.role }; + + const roles = this.reflector.getAllAndOverride(ROLES_KEY, [ + ctx.getHandler(), + ctx.getClass(), + ]); + if (roles?.length && !roles.includes(user.role)) { + throw new ForbiddenException("برای این کار دسترسی ندارید"); + } + return true; + } +} + +/** Protects a controller (or route) with a valid access token; optionally limits it to roles */ +export const AdminAuth = (...roles: Role[]) => + applyDecorators(SetMetadata(ROLES_KEY, roles), UseGuards(JwtAuthGuard)); + +/** Narrows the roles of one route inside an `@AdminAuth()` controller */ +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); + +export const CurrentUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): AuthUser => + ctx.switchToHttp().getRequest().user!, +); diff --git a/apps/api/src/common/common.module.ts b/apps/api/src/common/common.module.ts new file mode 100644 index 0000000..55fb8d7 --- /dev/null +++ b/apps/api/src/common/common.module.ts @@ -0,0 +1,60 @@ +import { Global, Injectable, Logger, Module } from "@nestjs/common"; +import { env } from "../config/env.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +/** Records who changed what; failures never break the request */ +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor(private readonly prisma: PrismaService) {} + + log( + userId: number | null, + action: string, + entity: string, + entityId: string | number = "", + summary = "", + ) { + this.prisma.auditLog + .create({ data: { userId, action, entity, entityId: String(entityId), summary } }) + .catch((e: unknown) => this.logger.warn(`audit log failed: ${String(e)}`)); + } +} + +/** + * Tells the Nuxt site to drop its page cache after content changes. + * Calls are debounced so a burst of edits triggers one revalidation. + */ +@Injectable() +export class RevalidateService { + private readonly logger = new Logger(RevalidateService.name); + private timer: NodeJS.Timeout | undefined; + + trigger() { + if (!env.WEB_REVALIDATE_URL) return; + clearTimeout(this.timer); + this.timer = setTimeout(() => void this.run(), 1500); + this.timer.unref(); + } + + private async run() { + try { + const res = await fetch(env.WEB_REVALIDATE_URL!, { + method: "POST", + headers: { "x-revalidate-secret": env.REVALIDATE_SECRET ?? "" }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) this.logger.warn(`site revalidation returned ${res.status}`); + } catch (e) { + this.logger.warn(`site revalidation failed: ${String(e)}`); + } + } +} + +@Global() +@Module({ + providers: [AuditService, RevalidateService], + exports: [AuditService, RevalidateService], +}) +export class CommonModule {} diff --git a/apps/api/src/common/prisma-exception.filter.ts b/apps/api/src/common/prisma-exception.filter.ts new file mode 100644 index 0000000..9d646f6 --- /dev/null +++ b/apps/api/src/common/prisma-exception.filter.ts @@ -0,0 +1,59 @@ +import { type ArgumentsHost, Catch, type ExceptionFilter, HttpStatus } from "@nestjs/common"; +import type { Response } from "express"; +import { Prisma } from "../generated/prisma/client.js"; + +const FIELD_LABELS: Record = { + slug: "اسلاگ", + code: "کد", + email: "ایمیل", +}; + +interface UniqueMeta { + target?: string[] | string; + driverAdapterError?: { cause?: { constraint?: { fields?: string[]; index?: string } } }; +} + +/** Fields of a violated unique constraint; with driver adapters only the index name is given */ +function uniqueFields(meta: UniqueMeta | undefined): string[] { + if (meta?.target) return Array.isArray(meta.target) ? meta.target : [meta.target]; + const constraint = meta?.driverAdapterError?.cause?.constraint; + if (constraint?.fields) return constraint.fields; + // "Product_code_key" → ["code"], "ProductGroup_categoryId_slug_key" → ["categoryId", "slug"] + const match = constraint?.index?.match(/^[^_]+_(.+)_key$/); + return match ? match[1]!.split("_") : []; +} + +/** Maps common Prisma errors to clear HTTP responses instead of 500s */ +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + catch(error: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const res = host.switchToHttp().getResponse(); + + switch (error.code) { + case "P2002": { + const fields = uniqueFields(error.meta as UniqueMeta | undefined) + .filter((f) => !f.endsWith("Id")) // scoping columns like categoryId + .map((f) => FIELD_LABELS[f] ?? f) + .join("، "); + return res.status(HttpStatus.CONFLICT).json({ + statusCode: HttpStatus.CONFLICT, + message: fields ? `مقدار «${fields}» تکراری است` : "مقدار تکراری است", + }); + } + case "P2003": + return res.status(HttpStatus.CONFLICT).json({ + statusCode: HttpStatus.CONFLICT, + message: "این مورد به داده‌های دیگری وابسته است", + }); + case "P2025": + return res + .status(HttpStatus.NOT_FOUND) + .json({ statusCode: HttpStatus.NOT_FOUND, message: "مورد پیدا نشد" }); + default: + return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: "خطای پایگاه داده", + }); + } + } +} diff --git a/apps/api/src/common/zod.pipe.ts b/apps/api/src/common/zod.pipe.ts new file mode 100644 index 0000000..31e6a37 --- /dev/null +++ b/apps/api/src/common/zod.pipe.ts @@ -0,0 +1,26 @@ +import { BadRequestException, type PipeTransform } from "@nestjs/common"; +import { toIssues } from "@imen/shared"; +import { z } from "zod"; + +// Persian default messages for every schema that has no custom message +z.config(z.locales.fa()); + +/** Validates and parses a request part with a zod schema: `@Body(new ZodPipe(schema))` */ +export class ZodPipe implements PipeTransform> { + constructor(private readonly schema: T) {} + + transform(value: unknown): z.output { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new BadRequestException({ + statusCode: 400, + message: "اطلاعات واردشده معتبر نیست", + issues: toIssues(result.error), + }); + } + return result.data; + } +} + +/** Parses an `:id` route param */ +export const IdPipe = new ZodPipe(z.coerce.number().int().positive()); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..f9a791a --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { z } from "zod"; + +if (existsSync(".env")) process.loadEnvFile(".env"); + +const bool = (fallback: boolean) => + z + .enum(["true", "false", "1", "0"]) + .optional() + .transform((v) => (v === undefined ? fallback : v === "true" || v === "1")); + +const schema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + PORT: z.coerce.number().int().default(4000), + CORS_ORIGINS: z.string().default(""), + /** number of reverse proxies in front of the API (gateway nginx = 1, + host TLS proxy = 2) */ + TRUST_PROXY: z.coerce.number().int().min(0).default(1), + + DATABASE_URL: z.string().min(1), + + JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"), + JWT_ACCESS_TTL: z.string().default("15m"), + REFRESH_TTL_DAYS: z.coerce.number().int().positive().default(30), + COOKIE_SECURE: bool(true), + + S3_ENDPOINT: z.string().optional(), + S3_REGION: z.string().default("us-east-1"), + S3_BUCKET: z.string().default("imen-media"), + S3_ACCESS_KEY: z.string().default(""), + S3_SECRET_KEY: z.string().default(""), + S3_FORCE_PATH_STYLE: bool(true), + S3_ENSURE_BUCKET: bool(false), + MEDIA_PUBLIC_URL: z + .string() + .default("/media") + .transform((v) => v.replace(/\/+$/, "")), + + WEB_REVALIDATE_URL: z.string().optional(), + REVALIDATE_SECRET: z.string().optional(), + + ADMIN_EMAIL: z.string().optional(), + ADMIN_PASSWORD: z.string().optional(), + ADMIN_NAME: z.string().default("مدیر سایت"), +}); + +const parsed = schema.safeParse(process.env); +if (!parsed.success) { + console.error("Invalid environment variables:\n" + z.prettifyError(parsed.error)); + process.exit(1); +} + +export const env = parsed.data; +export const isProd = env.NODE_ENV === "production"; diff --git a/apps/api/src/contact/contact.module.ts b/apps/api/src/contact/contact.module.ts new file mode 100644 index 0000000..cf9c82c --- /dev/null +++ b/apps/api/src/contact/contact.module.ts @@ -0,0 +1,137 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Injectable, + Module, + Param, + Patch, + Post, + Query, + Req, +} from "@nestjs/common"; +import { Throttle } from "@nestjs/throttler"; +import { + type ContactInput, + contactInputSchema, + type ContactMessageDto, + MESSAGE_STATUSES, + type MessageStatus, + messageUpdateSchema, + type Paginated, +} from "@imen/shared"; +import type { Request } from "express"; +import { z } from "zod"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { AuditService } from "../common/common.module.js"; +import { IdPipe, ZodPipe } from "../common/zod.pipe.js"; +import type { ContactMessage } from "../generated/prisma/client.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +const listQuery = z.object({ + status: z.enum(MESSAGE_STATUSES).optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const toMessageDto = (m: ContactMessage): ContactMessageDto => ({ + id: m.id, + name: m.name, + org: m.org, + phone: m.phone, + message: m.message, + status: m.status, + createdAt: m.createdAt.toISOString(), +}); + +@Injectable() +export class ContactService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async submit(input: ContactInput, ip: string, userAgent: string) { + // honeypot filled → pretend success so bots learn nothing + if (input.website) return; + await this.prisma.contactMessage.create({ + data: { + name: input.name, + org: input.org, + phone: input.phone, + message: input.message, + ip: ip.slice(0, 64), + userAgent: userAgent.slice(0, 300), + }, + }); + } + + async list(q: z.output): Promise> { + const where = q.status ? { status: q.status } : {}; + const [items, total] = await this.prisma.$transaction([ + this.prisma.contactMessage.findMany({ + where, + orderBy: { id: "desc" }, + skip: (q.page - 1) * q.pageSize, + take: q.pageSize, + }), + this.prisma.contactMessage.count({ where }), + ]); + return { items: items.map(toMessageDto), total, page: q.page, pageSize: q.pageSize }; + } + + async setStatus(id: number, status: MessageStatus) { + return toMessageDto(await this.prisma.contactMessage.update({ where: { id }, data: { status } })); + } + + async remove(id: number, userId: number) { + const m = await this.prisma.contactMessage.delete({ where: { id } }); + this.audit.log(userId, "delete", "message", id, m.name); + } +} + +@Controller("public/contact") +export class PublicContactController { + constructor(private readonly contact: ContactService) {} + + @Post() + @HttpCode(201) + @Throttle({ default: { limit: 5, ttl: 10 * 60_000 } }) + async submit(@Body(new ZodPipe(contactInputSchema)) body: ContactInput, @Req() req: Request) { + await this.contact.submit(body, req.ip ?? "", req.headers["user-agent"] ?? ""); + return { ok: true }; + } +} + +@AdminAuth() +@Controller("admin/messages") +export class MessagesController { + constructor(private readonly contact: ContactService) {} + + @Get() + list(@Query(new ZodPipe(listQuery)) q: z.output) { + return this.contact.list(q); + } + + @Patch(":id") + update( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(messageUpdateSchema)) body: z.output, + ) { + return this.contact.setStatus(id, body.status); + } + + @Delete(":id") + @HttpCode(204) + async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { + await this.contact.remove(id, user.id); + } +} + +@Module({ + controllers: [PublicContactController, MessagesController], + providers: [ContactService], +}) +export class ContactModule {} diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..4dbcf98 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Module } from "@nestjs/common"; +import type { DashboardDto } from "@imen/shared"; +import { AdminAuth } from "../common/auth.js"; +import { toMessageDto } from "../contact/contact.module.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +@AdminAuth() +@Controller("admin/dashboard") +export class DashboardController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + async get(): Promise { + const [categories, products, draftProducts, newMessages, media, messages, activity] = + await Promise.all([ + this.prisma.category.count(), + this.prisma.product.count(), + this.prisma.product.count({ where: { isPublished: false } }), + this.prisma.contactMessage.count({ where: { status: "NEW" } }), + this.prisma.media.count(), + this.prisma.contactMessage.findMany({ orderBy: { id: "desc" }, take: 5 }), + this.prisma.auditLog.findMany({ + orderBy: { id: "desc" }, + take: 10, + include: { user: { select: { name: true } } }, + }), + ]); + + return { + categories, + products, + draftProducts, + newMessages, + media, + recentMessages: messages.map(toMessageDto), + recentActivity: activity.map((a) => ({ + id: a.id, + action: a.action, + entity: a.entity, + summary: a.summary, + user: a.user?.name ?? null, + createdAt: a.createdAt.toISOString(), + })), + }; + } +} + +@Module({ controllers: [DashboardController] }) +export class DashboardModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..1a740e3 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,26 @@ +import { env } from "./config/env.js"; +import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import cookieParser from "cookie-parser"; +import helmet from "helmet"; +import { AppModule } from "./app.module.js"; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // behind nginx: take the client IP from X-Forwarded-For (rate limiting, contact form) + app.set("trust proxy", env.TRUST_PROXY); + app.setGlobalPrefix("api"); + app.use(helmet()); + app.use(cookieParser()); + + const origins = env.CORS_ORIGINS.split(",") + .map((o) => o.trim()) + .filter(Boolean); + if (origins.length) app.enableCors({ origin: origins, credentials: true }); + + app.enableShutdownHooks(); + await app.listen(env.PORT); +} + +await bootstrap(); diff --git a/apps/api/src/media/media.controller.ts b/apps/api/src/media/media.controller.ts new file mode 100644 index 0000000..bc5d74d --- /dev/null +++ b/apps/api/src/media/media.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Module, + Param, + Patch, + Post, + Query, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { mediaUpdateSchema } from "@imen/shared"; +import { z } from "zod"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { IdPipe, ZodPipe } from "../common/zod.pipe.js"; +import { MediaService } from "./media.service.js"; + +const listQuery = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(40), + q: z.string().trim().max(100).optional(), +}); + +@AdminAuth() +@Controller("admin/media") +export class MediaController { + constructor(private readonly media: MediaService) {} + + @Get() + list(@Query(new ZodPipe(listQuery)) q: z.output) { + return this.media.list(q.page, q.pageSize, q.q); + } + + @Post() + @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 15 * 1024 * 1024 } })) + upload( + @UploadedFile() file: Express.Multer.File | undefined, + @Body("alt") alt: unknown, + @CurrentUser() user: AuthUser, + ) { + return this.media.upload(file, typeof alt === "string" ? alt : "", user.id); + } + + @Patch(":id") + update( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(mediaUpdateSchema)) body: z.output, + @CurrentUser() user: AuthUser, + ) { + return this.media.updateAlt(id, body.alt, user.id); + } + + @Delete(":id") + @HttpCode(204) + async remove(@Param("id", IdPipe) id: number, @CurrentUser() user: AuthUser) { + await this.media.remove(id, user.id); + } +} + +@Module({ + controllers: [MediaController], + providers: [MediaService], + exports: [MediaService], +}) +export class MediaModule {} diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts new file mode 100644 index 0000000..5ad3e30 --- /dev/null +++ b/apps/api/src/media/media.service.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + type OnModuleInit, +} from "@nestjs/common"; +import type { MediaDto, Paginated } from "@imen/shared"; +import { env } from "../config/env.js"; +import { AuditService, RevalidateService } from "../common/common.module.js"; +import type { Media } from "../generated/prisma/client.js"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { ACCEPTED_IMAGE_TYPES, S3Storage, toWebp } from "./s3-storage.js"; + +@Injectable() +export class MediaService implements OnModuleInit { + private readonly logger = new Logger(MediaService.name); + readonly storage = new S3Storage(); + + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly revalidate: RevalidateService, + ) {} + + async onModuleInit() { + if (!env.S3_ENSURE_BUCKET) return; + try { + await this.storage.ensureBucket(); + } catch (e) { + this.logger.error(`could not prepare bucket "${env.S3_BUCKET}": ${String(e)}`); + } + } + + url(key: string) { + return this.storage.publicUrl(key); + } + + toDto(m: Media): MediaDto { + return { + id: m.id, + url: this.url(m.key), + alt: m.alt, + mime: m.mime, + size: m.size, + width: m.width, + height: m.height, + originalName: m.originalName, + createdAt: m.createdAt.toISOString(), + }; + } + + async list(page: number, pageSize: number, q?: string): Promise> { + const where = q + ? { + OR: [ + { originalName: { contains: q, mode: "insensitive" as const } }, + { alt: { contains: q, mode: "insensitive" as const } }, + ], + } + : {}; + const [items, total] = await this.prisma.$transaction([ + this.prisma.media.findMany({ + where, + orderBy: { id: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.media.count({ where }), + ]); + return { items: items.map((m) => this.toDto(m)), total, page, pageSize }; + } + + async upload(file: Express.Multer.File | undefined, alt: string, userId: number) { + if (!file) throw new BadRequestException("فایلی ارسال نشده است"); + if (!ACCEPTED_IMAGE_TYPES.includes(file.mimetype)) { + throw new BadRequestException("فقط تصاویر JPG، PNG، WebP، AVIF و GIF پذیرفته می‌شوند"); + } + + let image: Awaited>; + try { + image = await toWebp(file.buffer); + } catch { + throw new BadRequestException("فایل تصویر قابل پردازش نیست"); + } + + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const key = `uploads/${now.getFullYear()}/${month}/${randomUUID()}.webp`; + await this.storage.put(key, image.data, "image/webp"); + + const media = await this.prisma.media.create({ + data: { + key, + mime: "image/webp", + size: image.size, + width: image.width, + height: image.height, + alt: alt.trim().slice(0, 200), + // multer decodes the filename as latin1 + originalName: Buffer.from(file.originalname, "latin1").toString("utf8").slice(0, 200), + }, + }); + this.audit.log(userId, "upload", "media", media.id, media.originalName); + return this.toDto(media); + } + + async updateAlt(id: number, alt: string, userId: number) { + const media = await this.prisma.media.update({ where: { id }, data: { alt } }); + this.audit.log(userId, "update", "media", id, media.originalName); + this.revalidate.trigger(); + return this.toDto(media); + } + + async remove(id: number, userId: number) { + const media = await this.prisma.media.findUnique({ + where: { id }, + include: { _count: { select: { categoryCovers: true, productImages: true, productGallery: true } } }, + }); + if (!media) throw new NotFoundException("فایل پیدا نشد"); + + const { categoryCovers, productImages, productGallery } = media._count; + const sections = await this.prisma.$queryRaw<{ key: string }[]>` + SELECT key FROM "SiteSection" WHERE data::text LIKE ${"%" + media.key + "%"}`; + if (categoryCovers + productImages + productGallery + sections.length > 0) { + throw new ConflictException( + "این تصویر در محصولات، دسته‌ها یا بخش‌های سایت استفاده شده و قابل حذف نیست", + ); + } + + await this.prisma.media.delete({ where: { id } }); + await this.storage.remove(media.key).catch((e: unknown) => { + this.logger.warn(`could not delete object ${media.key}: ${String(e)}`); + }); + this.audit.log(userId, "delete", "media", id, media.originalName); + } +} diff --git a/apps/api/src/media/s3-storage.ts b/apps/api/src/media/s3-storage.ts new file mode 100644 index 0000000..c747ba6 --- /dev/null +++ b/apps/api/src/media/s3-storage.ts @@ -0,0 +1,88 @@ +import { + CreateBucketCommand, + DeleteObjectCommand, + HeadBucketCommand, + PutBucketPolicyCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import sharp from "sharp"; +import { env } from "../config/env.js"; + +/** + * Thin wrapper over any S3-compatible storage (MinIO, ArvanCloud Object Storage, AWS ...). + * Plain class (no Nest decorators) so the seed script can use it too. + */ +export class S3Storage { + private readonly client = new S3Client({ + endpoint: env.S3_ENDPOINT, + region: env.S3_REGION, + forcePathStyle: env.S3_FORCE_PATH_STYLE, + credentials: { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY }, + }); + private readonly bucket = env.S3_BUCKET; + + publicUrl(key: string) { + return `${env.MEDIA_PUBLIC_URL}/${key}`; + } + + async put(key: string, body: Buffer, contentType: string) { + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentType: contentType, + // keys are unique per upload, so files never change + CacheControl: "public, max-age=31536000, immutable", + }), + ); + } + + async remove(key: string) { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } + + /** Creates the bucket if needed and allows anonymous reads (used with MinIO) */ + async ensureBucket() { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); + } catch { + await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })); + } + await this.client.send( + new PutBucketPolicyCommand({ + Bucket: this.bucket, + Policy: JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { AWS: ["*"] }, + Action: ["s3:GetObject"], + Resource: [`arn:aws:s3:::${this.bucket}/*`], + }, + ], + }), + }), + ); + } +} + +export const ACCEPTED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/webp", + "image/avif", + "image/gif", +]; + +/** Normalizes an uploaded image: fixes EXIF rotation, caps the size and converts to WebP */ +export async function toWebp(input: Buffer) { + const { data, info } = await sharp(input) + .rotate() + .resize({ width: 2000, height: 2000, fit: "inside", withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer({ resolveWithObject: true }); + return { data, width: info.width, height: info.height, size: info.size }; +} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..9c83036 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,22 @@ +import { Global, Injectable, Module, type OnModuleDestroy } from "@nestjs/common"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { env } from "../config/env.js"; +import { PrismaClient } from "../generated/prisma/client.js"; + +export const createPrismaClient = () => + new PrismaClient({ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }) }); + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleDestroy { + constructor() { + super({ adapter: new PrismaPg({ connectionString: env.DATABASE_URL }) }); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} + +@Global() +@Module({ providers: [PrismaService], exports: [PrismaService] }) +export class PrismaModule {} diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts new file mode 100644 index 0000000..7738999 --- /dev/null +++ b/apps/api/src/public/public.module.ts @@ -0,0 +1,122 @@ +import { Controller, Get, Injectable, Module, NotFoundException, Param } from "@nestjs/common"; +import type { + PublicCategory, + PublicCategorySummary, + PublicProduct, + PublicSite, +} from "@imen/shared"; +import { MediaModule } from "../media/media.controller.js"; +import { MediaService } from "../media/media.service.js"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { SectionsModule, SectionsService } from "../sections/sections.module.js"; + +/** Read-only data for the website. Only published categories and products are exposed. */ +@Injectable() +export class PublicService { + constructor( + private readonly prisma: PrismaService, + private readonly media: MediaService, + private readonly sections: SectionsService, + ) {} + + async site(): Promise { + const [sections, categories] = await Promise.all([this.sections.getAll(), this.categories()]); + return { sections, categories }; + } + + async categories(): Promise { + const rows = await this.prisma.category.findMany({ + where: { isPublished: true }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + include: { + coverImage: true, + _count: { select: { products: { where: { isPublished: true } } } }, + }, + }); + return rows.map((c) => ({ + slug: c.slug, + code: c.code, + name: c.name, + nameEn: c.nameEn, + shortDesc: c.shortDesc, + icon: c.icon, + coverImage: c.coverImage && this.media.url(c.coverImage.key), + productCount: c._count.products, + })); + } + + async category(slug: string): Promise { + const c = await this.prisma.category.findFirst({ + where: { slug, isPublished: true }, + include: { + coverImage: true, + groups: { orderBy: { sortOrder: "asc" } }, + products: { + where: { isPublished: true }, + orderBy: [{ sortOrder: "asc" }, { id: "asc" }], + include: { image: true, group: { select: { slug: true } } }, + }, + }, + }); + if (!c) throw new NotFoundException("دسته‌بندی یافت نشد"); + + const products: PublicProduct[] = c.products.map((p) => ({ + code: p.code, + slug: p.slug, + name: p.name, + nameEn: p.nameEn, + desc: p.desc, + specs: p.specs, + variants: p.variants, + image: p.image && this.media.url(p.image.key), + group: p.group?.slug ?? null, + })); + // hide groups that have no published products + const usedGroups = new Set(products.map((p) => p.group)); + + return { + slug: c.slug, + code: c.code, + name: c.name, + nameEn: c.nameEn, + shortDesc: c.shortDesc, + fullDesc: c.fullDesc, + icon: c.icon, + seoTitle: c.seoTitle, + seoDescription: c.seoDescription, + coverImage: c.coverImage && this.media.url(c.coverImage.key), + productCount: products.length, + groups: c.groups + .filter((g) => usedGroups.has(g.slug)) + .map((g) => ({ slug: g.slug, name: g.name, desc: g.desc })), + products, + }; + } +} + +@Controller("public") +export class PublicController { + constructor(private readonly publicData: PublicService) {} + + @Get("site") + site() { + return this.publicData.site(); + } + + @Get("categories") + categories() { + return this.publicData.categories(); + } + + @Get("categories/:slug") + category(@Param("slug") slug: string) { + return this.publicData.category(slug); + } +} + +@Module({ + imports: [MediaModule, SectionsModule], + controllers: [PublicController], + providers: [PublicService], +}) +export class PublicModule {} diff --git a/apps/api/src/sections/sections.module.ts b/apps/api/src/sections/sections.module.ts new file mode 100644 index 0000000..c795299 --- /dev/null +++ b/apps/api/src/sections/sections.module.ts @@ -0,0 +1,101 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + Injectable, + Module, + NotFoundException, + Param, + Put, + ServiceUnavailableException, +} from "@nestjs/common"; +import { + isSectionKey, + SECTION_KEYS, + sectionSchemas, + type SiteSections, + toIssues, +} from "@imen/shared"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { AuditService, RevalidateService } from "../common/common.module.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +@Injectable() +export class SectionsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly revalidate: RevalidateService, + ) {} + + /** All sections, parsed through their schemas so missing optional fields get defaults */ + async getAll(): Promise { + const rows = await this.prisma.siteSection.findMany(); + const byKey = new Map(rows.map((r) => [r.key, r.data])); + const missing = SECTION_KEYS.filter((k) => !byKey.has(k)); + if (missing.length) { + throw new ServiceUnavailableException( + `محتوای سایت مقداردهی نشده است (${missing.join(", ")}) — pnpm db:seed را اجرا کنید`, + ); + } + return Object.fromEntries( + SECTION_KEYS.map((k) => [k, sectionSchemas[k].parse(byKey.get(k))]), + ) as SiteSections; + } + + async get(key: string) { + if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد"); + const row = await this.prisma.siteSection.findUnique({ where: { key } }); + if (!row) throw new NotFoundException("بخش پیدا نشد"); + return { key, data: sectionSchemas[key].parse(row.data), updatedAt: row.updatedAt }; + } + + async update(key: string, body: unknown, user: AuthUser) { + if (!isSectionKey(key)) throw new NotFoundException("بخش پیدا نشد"); + // site-wide settings (contact info, SEO defaults) are limited to admins + if (key === "settings" && user.role !== "ADMIN") { + throw new ForbiddenException("فقط مدیر کل می‌تواند تنظیمات سایت را تغییر دهد"); + } + const parsed = sectionSchemas[key].safeParse(body); + if (!parsed.success) { + throw new BadRequestException({ + statusCode: 400, + message: "اطلاعات واردشده معتبر نیست", + issues: toIssues(parsed.error), + }); + } + const row = await this.prisma.siteSection.upsert({ + where: { key }, + create: { key, data: parsed.data, updatedById: user.id }, + update: { data: parsed.data, updatedById: user.id }, + }); + this.audit.log(user.id, "update", "section", key, key); + this.revalidate.trigger(); + return { key, data: parsed.data, updatedAt: row.updatedAt }; + } +} + +@AdminAuth() +@Controller("admin/sections") +export class SectionsController { + constructor(private readonly sections: SectionsService) {} + + @Get(":key") + get(@Param("key") key: string) { + return this.sections.get(key); + } + + @Put(":key") + update(@Param("key") key: string, @Body() body: unknown, @CurrentUser() user: AuthUser) { + return this.sections.update(key, body, user); + } +} + +@Module({ + controllers: [SectionsController], + providers: [SectionsService], + exports: [SectionsService], +}) +export class SectionsModule {} diff --git a/shared/data/categories.ts b/apps/api/src/seed/data/categories.ts similarity index 99% rename from shared/data/categories.ts rename to apps/api/src/seed/data/categories.ts index 49add54..9e85da1 100644 --- a/shared/data/categories.ts +++ b/apps/api/src/seed/data/categories.ts @@ -1,3 +1,8 @@ +/** + * Initial catalog, imported by the seed script. After seeding, the database (admin panel) is the + * source of truth — edits here only affect a fresh database. + * `image` paths are relative to apps/api/seed-assets ("/images/" prefix is stripped). + */ export interface Product { code: string; name: string; @@ -947,8 +952,3 @@ export const CATEGORIES: readonly Category[] = [ ], }, ] as const; - -export const findCategory = (slug: string): Category | undefined => - CATEGORIES.find((c) => c.slug === slug); - -export const categorySlugs: readonly string[] = CATEGORIES.map((c) => c.slug); diff --git a/apps/api/src/seed/data/sections.ts b/apps/api/src/seed/data/sections.ts new file mode 100644 index 0000000..7d066fc --- /dev/null +++ b/apps/api/src/seed/data/sections.ts @@ -0,0 +1,279 @@ +import type { SiteSections } from "@imen/shared"; + +/** + * Initial website content, copied from the original hard-coded components. + * Image URLs starting with "asset:" are uploaded from apps/api/seed-assets by the seed script. + */ +export const SECTIONS: SiteSections = { + settings: { + name: "ایمن پیشگامان", + nameEn: "Imen Pishgaman Safety", + title: "ایمن پیشگامان | تجهیزات ایمنی و حفاظت فردی صنعتی", + description: + "ایمن پیشگامان — تأمین‌کننده تخصصی تجهیزات حفاظت فردی (PPE)، بازرسی، آموزش و راهکارهای ایمنی صنعتی برای سازمان‌هایی که جان انسان‌ها را جدی می‌گیرند.", + phone: "+98-21-91008800", + phoneDisplay: "۰۲۱ - ۹۱ ۰۰ ۸۸ ۰۰", + email: "info@imenpishgaman.ir", + address: { + street: "خیابان کارگر شمالی، خیابان فر، کوچه دوم، پلاک ۱۲", + locality: "تهران", + country: "IR", + }, + workingHours: "شنبه تا پنجشنبه — ۸:۰۰ تا ۱۷:۰۰", + openingHours: { + days: ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday"], + opens: "08:00", + closes: "17:00", + }, + foundingDate: "2007", + socials: [ + { type: "website", url: "/" }, + { type: "email", url: "mailto:info@imenpishgaman.ir" }, + { type: "phone", url: "tel:+982191008800" }, + ], + ogImage: { + url: "asset:hero.jpg", + alt: "کارگر صنعتی با تجهیزات ایمنی کامل", + width: 1792, + height: 1008, + }, + }, + + hero: { + badge: "تأمین‌کننده رسمی تجهیزات حفاظت فردی (PPE)", + title: "ایمنی یعنی", + titleHighlight: "بازگشت به خانه", + intro: + "ایمن پیشگامان با بیش از ۱۸ سال تجربه، تأمین‌کننده تخصصی تجهیزات ایمنی و حفاظت فردی برای سازمان‌هایی است که جان انسان‌ها را جدی می‌گیرند؛ از کلاه ایمنی تا هارنس نجات، از مشاوره تا بازرسی و آموزش.", + primaryCta: { label: "درخواست مشاوره رایگان", href: "#contact" }, + secondaryCta: { label: "مشاهده محصولات", href: "#products" }, + chips: [ + { icon: "ShieldCheck", label: "ISO 9001:2015" }, + { icon: "BadgeCheck", label: "CE CERTIFIED" }, + { icon: "Award", label: "EN STANDARDS" }, + ], + miniStats: [ + { value: "۱۸+", label: "سال تجربه در صنعت ایمنی" }, + { value: "۸۵۰+", label: "سازمان تحت پوشش" }, + { value: "۹۸٪", label: "رضایت مشتریان" }, + ], + image: { url: "asset:hero.jpg", alt: "کارگر صنعتی با تجهیزات ایمنی کامل" }, + lastInspection: "۱۴۰۴/۰۸/۱۲", + }, + + tape: { + items: [ + "ISO 9001:2015", + "CE CERTIFIED", + "گارانتی اصالت کالا", + "تحویل سراسری کشوری", + "مشاوره و بازرسی تخصصی رایگان", + "EN STANDARDS", + "پشتیبانی ۲۴/۷ قراردادهای سازمانی", + "تست نمونه قبل از خرید", + ], + }, + + stats: { + items: [ + { end: 18, suffix: "+", label: "سال تجربه در صنعت ایمنی", note: "SINCE 2007" }, + { end: 2400, suffix: "+", label: "کد کالای ایمنی", note: "PPE CATALOG" }, + { end: 850, suffix: "+", label: "مشتری سازمانی فعال", note: "B2B CLIENTS" }, + { end: 98, suffix: "٪", label: "رضایت مشتریان", note: "SATISFACTION" }, + ], + }, + + productsIntro: { + head: { + tag: "۰۱ — محصولات", + title: "خط اول دفاع،\nتجهیزات ماست", + highlight: "", + desc: "هر محصول پیش از عرضه، زیر ذره‌بین کنترل کیفیت و تطبیق با استانداردهای جهانی قرار می‌گیرد.", + }, + ctaLabel: "دریافت کاتالوگ کامل", + trust: [ + { + icon: "BadgeCheck", + title: "گارانتی اصالت کالا", + desc: "تمام محصولات با فاکتور رسمی و کد رهگیری اصالت", + }, + { + icon: "PackageCheck", + title: "تست نمونه قبل از خرید", + desc: "نمونه‌های نمایشی برای ارزیابی تیم HSE شما", + }, + { + icon: "ShieldCheck", + title: "امکان قرارداد سالانه", + desc: "قیمت ثابت و تأمین مستمر برای سازمان‌های بزرگ", + }, + ], + }, + + about: { + head: { + tag: "۰۲ — درباره ما", + title: "ایمنی برای ما شعار نیست؛", + highlight: "قرارداد است.", + desc: "", + }, + body: "از سال ۱۳۸۶، زمانی که اولین کلاه ایمنی را به یک کارگاه کوچک در جنوب تهران فروختیم، باور ما همین یک جمله بود: پشت هر تجهیز ایمنی، یک خانواده هست که منتظر بازگشت عزیزش است. امروز با بیش از ۸۵۰ سازمان، از پالایشگاه‌ها تا معادن و سایت‌های ساختمانی، در کنار کسانی هستیم که ایمنی را یک خط قرمز می‌دانند.", + image: { url: "asset:about.jpg", alt: "انبار تجهیزات ایمنی ایمن پیشگامان" }, + badge: { value: "۱۸+", label: "سال حفاظت از\nجان نیروی کار" }, + timeline: [ + { year: "۱۳۸۶", text: "تأسیس ایمن پیشگامان در تهران با تمرکز بر تجهیزات حفاظت فردی" }, + { year: "۱۳۹۲", text: "توسعه شبکه توزیع به تمام ۳۱ استان کشور" }, + { year: "۱۳۹۸", text: "راه‌اندازی آزمایشگاه کنترل کیفیت و تست تجهیزات" }, + { year: "۱۴۰۲", text: "افتتاح مرکز تخصصی آموزش و بازرسی HSE" }, + ], + features: [ + { icon: "Globe", label: "نماینده رسمی ۴۰+ برند جهانی" }, + { icon: "Users", label: "تیم مهندسی و بازرسی HSE" }, + { icon: "Headset", label: "پشتیبانی ۲۴/۷ قراردادها" }, + ], + }, + + services: { + head: { + tag: "۰۳ — خدمات", + title: "فراتر از تأمین؛\nهمراهی کامل ایمنی", + highlight: "", + desc: "ما در ایمن پیشگامان، تجهیزات را تحویل می‌دهیم و کنار شما می‌مانیم؛ از نخستین ارزیابی ریسک تا آخرین تست دوره‌ای.", + }, + items: [ + { + icon: "ClipboardCheck", + title: "بازرسی و ارزیابی ریسک", + desc: "تیم مهندسی ما با بازدید از سایت شما، خطرات را شناسایی، اولویت‌بندی و برای هر کدام راهکار اجرایی مشخص با برآورد هزینه ارائه می‌دهد.", + items: [ + "ارزیابی HAZOP و HAZID", + "شناسایی و اولویت‌بندی خطرات", + "گزارش مدیریتی با راهکار اجرایی", + ], + }, + { + icon: "GraduationCap", + title: "آموزش تخصصی ایمنی", + desc: "از دوره‌های عمومی تا تربیت مربی؛ آموزش‌های عملی و میدانی که ایمنی را از دفتر کلان‌ها به رفتار روزمره پرسنل تبدیل می‌کند.", + items: [ + "دوره‌های عمومی و تخصصی HSE", + "تربیت مربی ایمنی سایت", + "مانور و شبیه‌سازی شرایط اضطراری", + ], + }, + { + icon: "Gauge", + title: "تست و کالیبراسیون تجهیزات", + desc: "تجهیزات ایمنی فقط وقتی قابل اعتمادند که تست شده باشند. تست دوره‌ای، سرویس و صدور گواهی معتبر را به تیم تخصصی ما بسپارید.", + items: [ + "تست هارنس و لاین نجات", + "کالیبراسیون آشکارسازهای گاز", + "سرویس دوره‌ای و صدور گواهی", + ], + }, + { + icon: "Truck", + title: "تأمین و لجستیک سازمانی", + desc: "با قرارداد سالانه، قیمت‌گذاری شفاف و انبار محلی در سایت شما، نگرانی کمبود تجهیزات را از فهرست دغدغه‌هایتان حذف کنید.", + items: [ + "قرارداد سالانه با قیمت ثابت", + "انبار محلی در سایت شما", + "تحویل سراسری در ۴۸ ساعت", + ], + }, + ], + }, + + standards: { + head: { + tag: "۰۴ — استانداردها", + title: "مهرتأیید جهانی\nروی هر محصول", + highlight: "", + desc: "ما فقط تجهیزاتی را عرضه می‌کنیم که تأییدیه معتبر بین‌المللی و تست آزمایشگاهی داشته باشند؛ بدون استثنا.", + }, + note: "شماره سریال تمام محصولات در پایگاه داده استانداردهای اروپا قابل استعلام است و گواهی تست هر بچ تحویل مشتری می‌شود.", + ctaLabel: "درخواست گواهی تست محصولات", + items: [ + { code: "EN 397", label: "کلاه ایمنی صنعتی" }, + { code: "EN 388", label: "دستکش‌های مکانیکی" }, + { code: "EN 166", label: "محافظ چشم و صورت" }, + { code: "EN 361", label: "هارنس ضدسقوط" }, + { code: "ISO 20345", label: "کفش ایمنی" }, + { code: "ISO 9001", label: "سیستم مدیریت کیفیت" }, + ], + }, + + industries: { + head: { + tag: "۰۵ — صنایع", + title: "حضور در سخت‌ترین\nمیدان‌های کاری", + highlight: "", + desc: "جایی که دمای محیط از ۵۰ درجه گذر می‌کند یا ارتفاع کار به ده‌ها متر می‌رسد، تجهیزات ما باید بی‌نقص عمل کنند — و عمل می‌کنند.", + }, + items: [ + { name: "نفت و گاز", nameEn: "OIL & GAS", count: "۱۴۰+", image: { url: "asset:industries/oil-gas.jpg", alt: "نفت و گاز" } }, + { name: "ساختمان و عمران", nameEn: "CONSTRUCTION", count: "۲۱۰+", image: { url: "asset:industries/construction.jpg", alt: "ساختمان و عمران" } }, + { name: "پتروشیمی", nameEn: "PETROCHEMICAL", count: "۹۰+", image: { url: "asset:industries/petrochemical.jpg", alt: "پتروشیمی" } }, + { name: "صنایع معدنی", nameEn: "MINING", count: "۷۵+", image: { url: "asset:industries/mining.jpg", alt: "صنایع معدنی" } }, + { name: "برق و انرژی", nameEn: "POWER & ENERGY", count: "۶۰+", image: { url: "asset:industries/power-energy.jpg", alt: "برق و انرژی" } }, + { name: "حمل‌ونقل و لجستیک", nameEn: "TRANSPORT", count: "۴۵+", image: { url: "asset:industries/transport.jpg", alt: "حمل‌ونقل و لجستیک" } }, + ], + }, + + testimonials: { + head: { tag: "۰۶ — اعتماد", title: "آنچه مشتریان ما می‌گویند", highlight: "", desc: "" }, + items: [ + { + text: "ایمن پیشگامان فقط تأمین‌کننده نیست؛ شریک ایمنی ماست. بازرسی‌های دوره‌ای و آموزش‌های میدانی‌شان نرخ حوادث سایت را به کمترین رقم تاریخ مجموعه رساند.", + name: "مهندس رضا کاظمی", + role: "مدیر HSE — مجتمع پتروشیمی خلیج فارس", + }, + { + text: "کیفیت تجهیزات و سرعت پاسخ‌گویی در سطحی بود که قرارداد سالانه را بدون هیچ تردیدی تمدید کردیم؛ از خط تولید تا دفتر مرکزی.", + name: "سارا محمدی", + role: "سرپرست تدارکات — هلدینگ ساختمانی آرکان", + }, + { + text: "۱۴۰ کیلومتر دورتر از سایت، در عرض ۲۴ ساعت تجهیزات حیاتی را تحویل گرفتیم. به این می‌گویند شریک واقعی کاری.", + name: "امیرحسین راد", + role: "مدیر پروژه — صنایع معدنی کاوه", + }, + ], + }, + + contact: { + head: { + tag: "۰۷ — تماس", + title: "بیایید ایمنی را\nبا هم جدی بگیریم", + highlight: "", + desc: "فرم را پر کنید تا کارشناسان ما برای هماهنگی جلسه بازدید رایگان سایت شما تماس بگیرند؛ یا مستقیم با ما در ارتباط باشید.", + }, + emergencyTitle: "پاسخ‌گویی اضطراری ۲۴/۷", + emergencyText: + "برای مشتریان دارای قرارداد سازمانی، خط ویژه حوادث و تأمین فوری تجهیزات در تمام ساعات شبانه‌روز فعال است.", + formTag: "// فرم درخواست مشاوره", + formTitle: "در کمتر از ۲۴ ساعت\nبا شما تماس می‌گیریم", + successTitle: "درخواست شما ثبت شد", + successText: "کارشناسان ما تا ۲۴ ساعت آینده برای هماهنگی جلسه بازدید با شما تماس می‌گیرند.", + }, + + footer: { + about: + "تأمین‌کننده تخصصی تجهیزات حفاظت فردی و راهکارهای ایمنی صنعتی؛ همراه سازمان‌ها از سال ۱۳۸۶ در مسیر صفر شدن حوادث کاری.", + copyright: "© ۱۴۰۴ ایمن پیشگامان — تمامی حقوق محفوظ است.", + certLine: "ISO 9001:2015 — CE CERTIFIED PPE SUPPLIER", + emergencyNote: "پشتیبانی اضطراری: ۲۴/۷", + }, +}; + +/** Cover image of each category (was CATEGORY_IMAGES in the components) */ +export const CATEGORY_COVERS: Record = { + workwear: "products/vest.jpg", + "safety-footwear": "products/shoes.jpg", + "protective-gloves": "products/gloves.jpg", + "safety-helmets": "products/helmet.jpg", + "eye-protection": "products/goggles.jpg", + "hearing-protection": "products/hearing.jpg", + "respiratory-protection": "products/respiratory.jpg", + "traffic-equipment": "products/traffic.jpg", +}; diff --git a/apps/api/src/seed/seed.ts b/apps/api/src/seed/seed.ts new file mode 100644 index 0000000..b9f7e0c --- /dev/null +++ b/apps/api/src/seed/seed.ts @@ -0,0 +1,176 @@ +/** + * Fills an empty database with the original website content. + * Safe to re-run: existing categories, products, sections and users are left untouched. + * + * pnpm db:seed (dev, via tsx) + * node dist/seed/seed.js (production container) + * SEED_OVERWRITE_SECTIONS=1 ... (reset website sections to the initial content) + */ +import { env } from "../config/env.js"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { SECTION_KEYS, sectionSchemas } from "@imen/shared"; +import { hashPassword } from "../auth/auth.service.js"; +import { S3Storage, toWebp } from "../media/s3-storage.js"; +import { createPrismaClient } from "../prisma/prisma.service.js"; +import { CATEGORIES } from "./data/categories.js"; +import { CATEGORY_COVERS, SECTIONS } from "./data/sections.js"; + +const ASSETS_DIR = + process.env.SEED_ASSETS_DIR ?? + path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../seed-assets"); +const OVERWRITE_SECTIONS = ["1", "true"].includes(process.env.SEED_OVERWRITE_SECTIONS ?? ""); + +const prisma = createPrismaClient(); +const storage = new S3Storage(); +const uploaded = new Map(); + +/** Uploads a file from seed-assets once (as WebP) and returns its media record */ +async function asset(rel: string, alt = "") { + const cached = uploaded.get(rel); + if (cached) return cached; + + const key = `seed/${rel.replace(/\.[^.]+$/, "")}.webp`; + let media = await prisma.media.findUnique({ where: { key } }); + if (!media) { + const image = await toWebp(await readFile(path.join(ASSETS_DIR, rel))); + await storage.put(key, image.data, "image/webp"); + media = await prisma.media.create({ + data: { + key, + mime: "image/webp", + size: image.size, + width: image.width, + height: image.height, + alt, + originalName: path.basename(rel), + }, + }); + } + const result = { id: media.id, url: storage.publicUrl(key) }; + uploaded.set(rel, result); + return result; +} + +/** Replaces every { url: "asset:" } inside section data with the uploaded file URL */ +async function resolveAssets(value: unknown): Promise { + if (Array.isArray(value)) return Promise.all(value.map(resolveAssets)); + if (value && typeof value === "object") { + const entries = await Promise.all( + Object.entries(value).map(async ([k, v]) => { + if (k === "url" && typeof v === "string" && v.startsWith("asset:")) { + return [k, (await asset(v.slice("asset:".length))).url]; + } + return [k, await resolveAssets(v)]; + }), + ); + return Object.fromEntries(entries); + } + return value; +} + +async function seedSections() { + for (const key of SECTION_KEYS) { + const exists = await prisma.siteSection.findUnique({ where: { key } }); + if (exists && !OVERWRITE_SECTIONS) continue; + const data = sectionSchemas[key].parse(await resolveAssets(SECTIONS[key])); + await prisma.siteSection.upsert({ where: { key }, create: { key, data }, update: { data } }); + console.log(` section ${key}`); + } +} + +async function seedCatalog() { + for (const [ci, c] of CATEGORIES.entries()) { + if (await prisma.category.findUnique({ where: { slug: c.slug } })) continue; + + const coverPath = CATEGORY_COVERS[c.slug]; + const cover = coverPath ? await asset(coverPath, c.name) : null; + const category = await prisma.category.create({ + data: { + slug: c.slug, + code: c.code, + name: c.name, + nameEn: c.en, + shortDesc: c.shortDesc, + fullDesc: c.fullDesc, + icon: c.icon, + coverImageId: cover?.id ?? null, + sortOrder: ci, + groups: { + create: (c.groups ?? []).map((g, i) => ({ + slug: g.slug, + name: g.name, + desc: g.desc, + sortOrder: i, + })), + }, + }, + include: { groups: true }, + }); + const groupIds = new Map(category.groups.map((g) => [g.slug, g.id])); + + for (const [pi, p] of c.products.entries()) { + const image = p.image ? await asset(p.image.replace(/^\/images\//, ""), p.name) : null; + await prisma.product.create({ + data: { + categoryId: category.id, + groupId: p.group ? (groupIds.get(p.group) ?? null) : null, + code: p.code, + slug: p.code.toLowerCase(), + name: p.name, + nameEn: p.en, + desc: p.desc, + specs: [...p.specs], + variants: [...(p.variants ?? [])], + imageId: image?.id ?? null, + sortOrder: pi, + }, + }); + } + console.log(` category ${c.slug} (${c.products.length} products)`); + } +} + +async function seedAdmin() { + if (!env.ADMIN_EMAIL || !env.ADMIN_PASSWORD) { + console.log(" ADMIN_EMAIL / ADMIN_PASSWORD not set — skipping admin user"); + return; + } + const email = env.ADMIN_EMAIL.trim().toLowerCase(); + if (await prisma.adminUser.findUnique({ where: { email } })) return; + await prisma.adminUser.create({ + data: { + email, + name: env.ADMIN_NAME, + role: "ADMIN", + passwordHash: await hashPassword(env.ADMIN_PASSWORD), + }, + }); + console.log(` admin user ${email}`); +} + +async function main() { + if (env.S3_ENSURE_BUCKET) await storage.ensureBucket(); + console.log("seeding sections…"); + await seedSections(); + console.log("seeding catalog…"); + await seedCatalog(); + console.log("seeding admin…"); + await seedAdmin(); + const [categories, products, media] = await Promise.all([ + prisma.category.count(), + prisma.product.count(), + prisma.media.count(), + ]); + console.log(`done: ${categories} categories, ${products} products, ${media} media files`); +} + +try { + await main(); +} catch (e) { + console.error(e); + process.exitCode = 1; +} finally { + await prisma.$disconnect(); +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..87588e3 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,118 @@ +import { + BadRequestException, + Body, + Controller, + Delete, + Get, + HttpCode, + Injectable, + Module, + Param, + Post, + Put, +} from "@nestjs/common"; +import { + type UserCreateInput, + userCreateSchema, + type UserUpdateInput, + userUpdateSchema, +} from "@imen/shared"; +import { AdminAuth, type AuthUser, CurrentUser } from "../common/auth.js"; +import { AuditService } from "../common/common.module.js"; +import { IdPipe, ZodPipe } from "../common/zod.pipe.js"; +import { AuthModule } from "../auth/auth.controller.js"; +import { AuthService, hashPassword, toUserDto } from "../auth/auth.service.js"; +import { PrismaService } from "../prisma/prisma.service.js"; + +@Injectable() +export class UsersService { + constructor( + private readonly prisma: PrismaService, + private readonly auth: AuthService, + private readonly audit: AuditService, + ) {} + + async list() { + const users = await this.prisma.adminUser.findMany({ orderBy: { id: "asc" } }); + return users.map(toUserDto); + } + + async create(input: UserCreateInput, actor: AuthUser) { + const user = await this.prisma.adminUser.create({ + data: { + email: input.email, + name: input.name, + role: input.role, + passwordHash: await hashPassword(input.password), + }, + }); + this.audit.log(actor.id, "create", "user", user.id, user.email); + return toUserDto(user); + } + + async update(id: number, input: UserUpdateInput, actor: AuthUser) { + if (id === actor.id && (input.role !== "ADMIN" || !input.isActive)) { + throw new BadRequestException("نمی‌توانید نقش یا وضعیت حساب خودتان را تغییر دهید"); + } + const user = await this.prisma.adminUser.update({ + where: { id }, + data: { + name: input.name, + role: input.role, + isActive: input.isActive, + ...(input.password ? { passwordHash: await hashPassword(input.password) } : {}), + }, + }); + // a password reset or deactivation ends the user's existing sessions + if (input.password || !input.isActive) await this.auth.revokeAll(id); + this.audit.log(actor.id, "update", "user", id, user.email); + return toUserDto(user); + } + + async remove(id: number, actor: AuthUser) { + if (id === actor.id) throw new BadRequestException("نمی‌توانید حساب خودتان را حذف کنید"); + const user = await this.prisma.adminUser.delete({ where: { id } }); + this.audit.log(actor.id, "delete", "user", id, user.email); + } +} + +@AdminAuth("ADMIN") +@Controller("admin/users") +export class UsersController { + constructor(private readonly users: UsersService) {} + + @Get() + list() { + return this.users.list(); + } + + @Post() + create( + @Body(new ZodPipe(userCreateSchema)) body: UserCreateInput, + @CurrentUser() actor: AuthUser, + ) { + return this.users.create(body, actor); + } + + @Put(":id") + update( + @Param("id", IdPipe) id: number, + @Body(new ZodPipe(userUpdateSchema)) body: UserUpdateInput, + @CurrentUser() actor: AuthUser, + ) { + return this.users.update(id, body, actor); + } + + @Delete(":id") + @HttpCode(204) + async remove(@Param("id", IdPipe) id: number, @CurrentUser() actor: AuthUser) { + await this.users.remove(id, actor); + } +} + +@Module({ + imports: [AuthModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/test/api.e2e-spec.ts b/apps/api/test/api.e2e-spec.ts new file mode 100644 index 0000000..360122f --- /dev/null +++ b/apps/api/test/api.e2e-spec.ts @@ -0,0 +1,158 @@ +import { randomBytes } from "node:crypto"; +import type { INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { SECTION_KEYS } from "@imen/shared"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import { AppModule } from "../src/app.module.js"; +import { hashPassword } from "../src/auth/auth.service.js"; +import { PrismaService } from "../src/prisma/prisma.service.js"; + +/** + * Runs against the development database (seeded). Every record it creates is removed afterwards; + * it uses its own temporary users so real sessions are never touched. + */ +describe("API (e2e)", () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = randomBytes(4).toString("hex"); + const admin = { email: `e2e-admin-${suffix}@test.local`, password: "e2e-Password-1" }; + const editor = { email: `e2e-editor-${suffix}@test.local`, password: "e2e-Password-2" }; + + const http = () => request(app.getHttpServer()); + const login = async (u: { email: string; password: string }) => { + const res = await http().post("/api/admin/auth/login").send(u).expect(200); + return { token: res.body.accessToken as string, cookie: res.headers["set-cookie"] as unknown as string[] }; + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix("api"); + app.use(cookieParser()); + await app.init(); + + prisma = app.get(PrismaService); + await prisma.adminUser.createMany({ + data: [ + { email: admin.email, name: "e2e admin", role: "ADMIN", passwordHash: await hashPassword(admin.password) }, + { email: editor.email, name: "e2e editor", role: "EDITOR", passwordHash: await hashPassword(editor.password) }, + ], + }); + }); + + afterAll(async () => { + await prisma.adminUser.deleteMany({ where: { email: { in: [admin.email, editor.email] } } }); + await app.close(); + }); + + describe("public", () => { + it("reports health", () => http().get("/api/health").expect(200, { ok: true })); + + it("returns every site section and the published categories", async () => { + const res = await http().get("/api/public/site").expect(200); + expect(Object.keys(res.body.sections).sort()).toEqual([...SECTION_KEYS].sort()); + expect(res.body.categories.length).toBeGreaterThan(0); + }); + + it("returns a category with its products, 404 for unknown slugs", async () => { + const { body: site } = await http().get("/api/public/site"); + const slug = site.categories[0].slug; + const res = await http().get(`/api/public/categories/${slug}`).expect(200); + expect(res.body.products.length).toBe(site.categories[0].productCount); + await http().get("/api/public/categories/no-such-category").expect(404); + }); + + it("validates the contact form", async () => { + const res = await http().post("/api/public/contact").send({ name: "", org: "x", phone: "abc" }).expect(400); + expect(res.body.issues.map((i: { path: string }) => i.path).sort()).toEqual(["name", "phone"]); + }); + + it("silently drops honeypot submissions", async () => { + const before = await prisma.contactMessage.count(); + await http() + .post("/api/public/contact") + .send({ name: "bot", org: "bot", phone: "0912 000 0000", website: "http://spam" }) + .expect(201); + expect(await prisma.contactMessage.count()).toBe(before); + }); + }); + + describe("auth", () => { + it("rejects admin routes without a token and wrong passwords", async () => { + await http().get("/api/admin/dashboard").expect(401); + await http().post("/api/admin/auth/login").send({ email: admin.email, password: "wrong-password" }).expect(401); + }); + + it("sets an httpOnly refresh cookie and rotates it", async () => { + const { token, cookie } = await login(admin); + expect(cookie.join(";")).toMatch(/imen_rt=.*HttpOnly/i); + await http().get("/api/admin/auth/me").set("authorization", `Bearer ${token}`).expect(200); + + const refreshed = await http().post("/api/admin/auth/refresh").set("Cookie", cookie).expect(200); + expect(refreshed.body.accessToken).toBeTruthy(); + + // replaying the old (rotated) cookie is treated as theft: all sessions end + await http().post("/api/admin/auth/refresh").set("Cookie", cookie).expect(401); + const newCookie = refreshed.headers["set-cookie"] as unknown as string[]; + await http().post("/api/admin/auth/refresh").set("Cookie", newCookie).expect(401); + }); + }); + + describe("roles", () => { + it("limits users and site settings to admins", async () => { + const { token } = await login(editor); + const auth = { authorization: `Bearer ${token}` }; + await http().get("/api/admin/users").set(auth).expect(403); + + const settings = await http().get("/api/admin/sections/settings").set(auth).expect(200); + await http().put("/api/admin/sections/settings").set(auth).send(settings.body.data).expect(403); + await http().get("/api/admin/dashboard").set(auth).expect(200); + }); + }); + + describe("catalog", () => { + it("creates, validates, hides and deletes a product", async () => { + const { token } = await login(admin); + const auth = { authorization: `Bearer ${token}` }; + const [category] = (await http().get("/api/admin/categories").set(auth)).body; + const code = `E2E-${suffix}`; + + const invalid = await http() + .post("/api/admin/products") + .set(auth) + .send({ categoryId: category.id, code: "", slug: "Not A Slug", name: "" }) + .expect(400); + expect(invalid.body.issues.length).toBeGreaterThanOrEqual(3); + + const created = await http() + .post("/api/admin/products") + .set(auth) + .send({ categoryId: category.id, code, slug: code.toLowerCase(), name: "محصول آزمایشی" }) + .expect(201); + + const duplicate = await http() + .post("/api/admin/products") + .set(auth) + .send({ categoryId: category.id, code, slug: `${code.toLowerCase()}-b`, name: "x" }) + .expect(409); + expect(duplicate.body.message).toContain("کد"); + + const published = await http().get(`/api/public/categories/${category.slug}`); + expect(published.body.products.some((p: { code: string }) => p.code === code)).toBe(true); + + await http().patch(`/api/admin/products/${created.body.id}/publish`).set(auth).send({ isPublished: false }).expect(200); + const hidden = await http().get(`/api/public/categories/${category.slug}`); + expect(hidden.body.products.some((p: { code: string }) => p.code === code)).toBe(false); + + await http().delete(`/api/admin/products/${created.body.id}`).set(auth).expect(204); + }); + + it("refuses to delete a category that still has products", async () => { + const { token } = await login(admin); + const auth = { authorization: `Bearer ${token}` }; + const [category] = (await http().get("/api/admin/categories").set(auth)).body; + await http().delete(`/api/admin/categories/${category.id}`).set(auth).expect(409); + }); + }); +}); diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..a1a025a --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node", "multer"] + }, + "include": ["src"], + "exclude": ["node_modules", "test", "dist", "**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..ce3665a --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": false, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictPropertyInitialization": false, + "types": ["node", "multer", "vitest/globals"] + }, + "include": ["src", "test", "prisma", "prisma.config.ts", "vitest.config.e2e.ts"] +} diff --git a/apps/api/vitest.config.e2e.ts b/apps/api/vitest.config.e2e.ts new file mode 100644 index 0000000..f380a79 --- /dev/null +++ b/apps/api/vitest.config.e2e.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +// e2e tests boot the real AppModule against the database in apps/api/.env +export default defineConfig({ + test: { + globals: true, + root: "./", + include: ["test/**/*.e2e-spec.ts"], + testTimeout: 30_000, + hookTimeout: 60_000, + fileParallelism: false, + }, +}); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..ddafe50 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,3 @@ +NUXT_API_INTERNAL_URL=http://localhost:4000/api +NUXT_REVALIDATE_SECRET=change-me-revalidate-secret +NUXT_PUBLIC_SITE_URL=https://imenpishgaman.ir diff --git a/app/app.vue b/apps/web/app/app.vue similarity index 76% rename from app/app.vue rename to apps/web/app/app.vue index 8ec109b..33455ec 100644 --- a/app/app.vue +++ b/apps/web/app/app.vue @@ -6,8 +6,12 @@ import { organizationSchema, websiteSchema } from "#shared/seo/schema"; const { $antdCache } = useNuxtApp(); +const site = useSiteStore(); +await callOnce("site", () => site.load()); + useSiteSeo(); -useJsonLd([organizationSchema(), websiteSchema()]); +const seo = useSeoContext(); +useJsonLd([organizationSchema(seo), websiteSchema(seo)]);