kybernus 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/ecommerce.d.ts +3 -0
- package/dist/cli/commands/ecommerce.d.ts.map +1 -0
- package/dist/cli/commands/ecommerce.js +164 -0
- package/dist/cli/commands/ecommerce.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/ecommerce/.env.example +10 -0
- package/templates/ecommerce/.github/workflows/ci.yml +102 -0
- package/templates/ecommerce/.github/workflows/deploy.yml +31 -0
- package/templates/ecommerce/.prettierrc +9 -0
- package/templates/ecommerce/Dockerfile +54 -0
- package/templates/ecommerce/README.md +295 -0
- package/templates/ecommerce/apps/api/.env.example +59 -0
- package/templates/ecommerce/apps/api/jest.config.ts +50 -0
- package/templates/ecommerce/apps/api/jest.integration.config.ts +45 -0
- package/templates/ecommerce/apps/api/package.json +59 -0
- package/templates/ecommerce/apps/api/prisma/migrations/20260306000137_init/migration.sql +184 -0
- package/templates/ecommerce/apps/api/prisma/migrations/migration_lock.toml +3 -0
- package/templates/ecommerce/apps/api/prisma/schema.prisma +181 -0
- package/templates/ecommerce/apps/api/prisma/seed.ts +159 -0
- package/templates/ecommerce/apps/api/src/__tests__/app.test.ts +39 -0
- package/templates/ecommerce/apps/api/src/__tests__/globalSetup.ts +34 -0
- package/templates/ecommerce/apps/api/src/__tests__/globalTeardown.ts +16 -0
- package/templates/ecommerce/apps/api/src/__tests__/setup.db.ts +18 -0
- package/templates/ecommerce/apps/api/src/__tests__/setup.env.ts +14 -0
- package/templates/ecommerce/apps/api/src/app.ts +133 -0
- package/templates/ecommerce/apps/api/src/application/admin/admin-user.service.ts +24 -0
- package/templates/ecommerce/apps/api/src/application/admin/dashboard.service.ts +102 -0
- package/templates/ecommerce/apps/api/src/application/auth/auth.service.ts +185 -0
- package/templates/ecommerce/apps/api/src/application/cart/cart.service.ts +151 -0
- package/templates/ecommerce/apps/api/src/application/cart/coupon.service.ts +51 -0
- package/templates/ecommerce/apps/api/src/application/catalog/catalog.service.ts +168 -0
- package/templates/ecommerce/apps/api/src/application/checkout/checkout.service.ts +114 -0
- package/templates/ecommerce/apps/api/src/application/orders/order.service.ts +93 -0
- package/templates/ecommerce/apps/api/src/application/ports/email.port.ts +3 -0
- package/templates/ecommerce/apps/api/src/application/ports/payment.port.ts +24 -0
- package/templates/ecommerce/apps/api/src/application/ports/shipping.port.ts +9 -0
- package/templates/ecommerce/apps/api/src/application/ports/storage.port.ts +3 -0
- package/templates/ecommerce/apps/api/src/application/ports/token-blacklist.port.ts +4 -0
- package/templates/ecommerce/apps/api/src/application/ports/token.port.ts +18 -0
- package/templates/ecommerce/apps/api/src/application/profile/profile.service.ts +76 -0
- package/templates/ecommerce/apps/api/src/domain/auth/user.entity.ts +109 -0
- package/templates/ecommerce/apps/api/src/domain/auth/user.repository.ts +11 -0
- package/templates/ecommerce/apps/api/src/domain/cart/cart.entity.ts +136 -0
- package/templates/ecommerce/apps/api/src/domain/cart/cart.repository.ts +8 -0
- package/templates/ecommerce/apps/api/src/domain/cart/coupon.entity.ts +58 -0
- package/templates/ecommerce/apps/api/src/domain/cart/coupon.repository.ts +10 -0
- package/templates/ecommerce/apps/api/src/domain/catalog/category.entity.ts +51 -0
- package/templates/ecommerce/apps/api/src/domain/catalog/category.repository.ts +10 -0
- package/templates/ecommerce/apps/api/src/domain/catalog/product.entity.ts +130 -0
- package/templates/ecommerce/apps/api/src/domain/catalog/product.repository.ts +28 -0
- package/templates/ecommerce/apps/api/src/domain/checkout/order.entity.ts +121 -0
- package/templates/ecommerce/apps/api/src/domain/checkout/order.repository.ts +11 -0
- package/templates/ecommerce/apps/api/src/domain/shared/AppError.ts +12 -0
- package/templates/ecommerce/apps/api/src/infrastructure/cache/redis.ts +16 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/admin.registry.ts +13 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/auth.registry.ts +34 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/cart.registry.ts +49 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/catalog.registry.ts +24 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/checkout.registry.ts +47 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/orders.registry.ts +6 -0
- package/templates/ecommerce/apps/api/src/infrastructure/config/registry/profile.registry.ts +4 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/cart.memory.repository.ts +33 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/category.memory.repository.ts +41 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/coupon.memory.repository.ts +55 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/order.memory.repository.ts +75 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/product.memory.repository.ts +100 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/in-memory/user.memory.repository.ts +54 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/prisma/auth/user.prisma.repository.ts +83 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/prisma/catalog/category.prisma.repository.ts +69 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/prisma/catalog/product.prisma.repository.ts +185 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/prisma/checkout/order.prisma.repository.ts +149 -0
- package/templates/ecommerce/apps/api/src/infrastructure/persistence/prisma-client.ts +17 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/email/email.registry.ts +18 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/email/ethereal.email.service.ts +38 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/email/noop.email.service.ts +12 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/email/smtp.email.service.ts +36 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/payment/stripe-webhook.handler.ts +83 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/payment/stripe.adapter.ts +39 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/shipping/mock.shipping.service.ts +17 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/storage/in-memory.storage.service.ts +11 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/storage/local-disk.storage.service.ts +27 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/storage/s3.storage.service.ts +52 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/storage/storage.registry.ts +19 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/token/redis.token.blacklist.ts +23 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/token/token.blacklist.ts +30 -0
- package/templates/ecommerce/apps/api/src/infrastructure/services/token/token.service.ts +136 -0
- package/templates/ecommerce/apps/api/src/modules/admin/__tests__/admin.routes.integration.test.ts +250 -0
- package/templates/ecommerce/apps/api/src/modules/admin/admin.controller.ts +116 -0
- package/templates/ecommerce/apps/api/src/modules/admin/admin.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/admin/admin.routes.ts +21 -0
- package/templates/ecommerce/apps/api/src/modules/admin/admin.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/admin/admin.user.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/auth.logout.redis.test.ts +104 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/auth.routes.integration.test.ts +211 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/auth.service.unit.test.ts +260 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/email.service.unit.test.ts +94 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/token.blacklist.redis.test.ts +65 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/user.entity.unit.test.ts +79 -0
- package/templates/ecommerce/apps/api/src/modules/auth/__tests__/user.prisma.repository.test.ts +138 -0
- package/templates/ecommerce/apps/api/src/modules/auth/auth.controller.ts +148 -0
- package/templates/ecommerce/apps/api/src/modules/auth/auth.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/auth.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/modules/auth/auth.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/redis.token.blacklist.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/token.blacklist.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/auth/token.service.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/auth/user.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/user.prisma.repository.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/auth/user.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/cart/__tests__/cart.entity.unit.test.ts +144 -0
- package/templates/ecommerce/apps/api/src/modules/cart/__tests__/cart.routes.integration.test.ts +242 -0
- package/templates/ecommerce/apps/api/src/modules/cart/__tests__/cart.service.unit.test.ts +151 -0
- package/templates/ecommerce/apps/api/src/modules/cart/__tests__/coupon.admin.integration.test.ts +136 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.controller.ts +94 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/modules/cart/cart.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/cart/coupon.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/cart/coupon.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/cart/coupon.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/cart/shipping.service.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/__tests__/catalog.routes.integration.test.ts +275 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/__tests__/catalog.service.unit.test.ts +223 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/__tests__/product.image.integration.test.ts +130 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/__tests__/product.prisma.repository.test.ts +174 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/catalog.controller.ts +176 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/catalog.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/catalog.routes.ts +38 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/catalog.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/category.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/category.prisma.repository.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/category.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/product.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/product.prisma.repository.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/catalog/product.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/__tests__/checkout.routes.integration.test.ts +163 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/__tests__/checkout.service.unit.test.ts +191 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/__tests__/order.prisma.repository.test.ts +150 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/checkout.controller.ts +59 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/checkout.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/checkout.routes.ts +18 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/checkout.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/order.entity.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/order.prisma.repository.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/order.repository.ts +2 -0
- package/templates/ecommerce/apps/api/src/modules/checkout/tax.service.ts +9 -0
- package/templates/ecommerce/apps/api/src/modules/orders/__tests__/order.entity.unit.test.ts +68 -0
- package/templates/ecommerce/apps/api/src/modules/orders/__tests__/order.routes.integration.test.ts +254 -0
- package/templates/ecommerce/apps/api/src/modules/orders/__tests__/order.service.email.unit.test.ts +142 -0
- package/templates/ecommerce/apps/api/src/modules/orders/order.controller.ts +96 -0
- package/templates/ecommerce/apps/api/src/modules/orders/order.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/orders/order.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/modules/orders/order.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/payment/__tests__/stripe-webhook.unit.test.ts +330 -0
- package/templates/ecommerce/apps/api/src/modules/payment/__tests__/stripe.adapter.unit.test.ts +84 -0
- package/templates/ecommerce/apps/api/src/modules/payment/adapters/stripe.adapter.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/payment/payment.port.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/payment/stripe-webhook.handler.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/profile/__tests__/profile.routes.integration.test.ts +180 -0
- package/templates/ecommerce/apps/api/src/modules/profile/__tests__/profile.service.unit.test.ts +187 -0
- package/templates/ecommerce/apps/api/src/modules/profile/profile.controller.ts +92 -0
- package/templates/ecommerce/apps/api/src/modules/profile/profile.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/modules/profile/profile.routes.ts +14 -0
- package/templates/ecommerce/apps/api/src/modules/profile/profile.service.ts +1 -0
- package/templates/ecommerce/apps/api/src/presentation/middlewares/authenticate.ts +37 -0
- package/templates/ecommerce/apps/api/src/presentation/middlewares/authorize.ts +23 -0
- package/templates/ecommerce/apps/api/src/presentation/middlewares/errorHandler.ts +48 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/admin/admin.controller.ts +116 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/admin/admin.routes.ts +21 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/auth/auth.controller.ts +147 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/auth/auth.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/cart/cart.controller.ts +94 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/cart/cart.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/catalog/catalog.controller.ts +176 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/catalog/catalog.routes.ts +38 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/checkout/checkout.controller.ts +59 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/checkout/checkout.routes.ts +18 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/orders/order.controller.ts +96 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/orders/order.routes.ts +17 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/profile/profile.controller.ts +92 -0
- package/templates/ecommerce/apps/api/src/presentation/modules/profile/profile.routes.ts +14 -0
- package/templates/ecommerce/apps/api/src/presentation/validators/uuidParam.ts +20 -0
- package/templates/ecommerce/apps/api/src/server.ts +47 -0
- package/templates/ecommerce/apps/api/src/shared/__tests__/uuid.validation.test.ts +111 -0
- package/templates/ecommerce/apps/api/src/shared/errors/AppError.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/EtherealEmailService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/IEmailService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/NoopEmailService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/SmtpEmailService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/__tests__/ethereal.email.integration.test.ts +32 -0
- package/templates/ecommerce/apps/api/src/shared/infra/email/email.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/prisma.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/redis.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/IStorageService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/InMemoryStorageService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/LocalDiskStorageService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/S3StorageService.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/__tests__/s3.storage.unit.test.ts +73 -0
- package/templates/ecommerce/apps/api/src/shared/infra/storage/storage.registry.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/middlewares/authenticate.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/middlewares/authorize.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/middlewares/errorHandler.ts +1 -0
- package/templates/ecommerce/apps/api/src/shared/validators/uuidParam.ts +1 -0
- package/templates/ecommerce/apps/api/tsconfig.json +15 -0
- package/templates/ecommerce/apps/web/.env.example +8 -0
- package/templates/ecommerce/apps/web/index.html +19 -0
- package/templates/ecommerce/apps/web/jest.config.ts +45 -0
- package/templates/ecommerce/apps/web/package.json +38 -0
- package/templates/ecommerce/apps/web/src/App.tsx +133 -0
- package/templates/ecommerce/apps/web/src/__mocks__/fileMock.ts +1 -0
- package/templates/ecommerce/apps/web/src/__mocks__/styleMock.ts +1 -0
- package/templates/ecommerce/apps/web/src/index.css +159 -0
- package/templates/ecommerce/apps/web/src/main.tsx +13 -0
- package/templates/ecommerce/apps/web/src/modules/admin/__tests__/CouponsAdminPage.test.tsx +134 -0
- package/templates/ecommerce/apps/web/src/modules/admin/__tests__/DashboardPage.test.tsx +65 -0
- package/templates/ecommerce/apps/web/src/modules/admin/__tests__/OrdersAdminPage.test.tsx +79 -0
- package/templates/ecommerce/apps/web/src/modules/admin/__tests__/ProductsAdminPage.test.tsx +84 -0
- package/templates/ecommerce/apps/web/src/modules/admin/__tests__/UsersAdminPage.test.tsx +85 -0
- package/templates/ecommerce/apps/web/src/modules/admin/pages/CouponsAdminPage.tsx +179 -0
- package/templates/ecommerce/apps/web/src/modules/admin/pages/DashboardPage.tsx +58 -0
- package/templates/ecommerce/apps/web/src/modules/admin/pages/OrdersAdminPage.tsx +178 -0
- package/templates/ecommerce/apps/web/src/modules/admin/pages/ProductsAdminPage.tsx +444 -0
- package/templates/ecommerce/apps/web/src/modules/admin/pages/UsersAdminPage.tsx +87 -0
- package/templates/ecommerce/apps/web/src/modules/auth/LoginForm.tsx +91 -0
- package/templates/ecommerce/apps/web/src/modules/auth/RegisterForm.tsx +109 -0
- package/templates/ecommerce/apps/web/src/modules/auth/__tests__/ForgotPasswordPage.test.tsx +42 -0
- package/templates/ecommerce/apps/web/src/modules/auth/__tests__/LoginForm.test.tsx +76 -0
- package/templates/ecommerce/apps/web/src/modules/auth/__tests__/RegisterForm.test.tsx +62 -0
- package/templates/ecommerce/apps/web/src/modules/auth/__tests__/ResetPasswordPage.test.tsx +66 -0
- package/templates/ecommerce/apps/web/src/modules/auth/pages/ForgotPasswordPage.tsx +100 -0
- package/templates/ecommerce/apps/web/src/modules/auth/pages/LoginPage.tsx +39 -0
- package/templates/ecommerce/apps/web/src/modules/auth/pages/RegisterPage.tsx +39 -0
- package/templates/ecommerce/apps/web/src/modules/auth/pages/ResetPasswordPage.tsx +110 -0
- package/templates/ecommerce/apps/web/src/modules/auth/useAuthStore.ts +141 -0
- package/templates/ecommerce/apps/web/src/modules/cart/__tests__/CartPage.test.tsx +111 -0
- package/templates/ecommerce/apps/web/src/modules/cart/pages/CartPage.tsx +313 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/__tests__/ProductCard.test.tsx +59 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/__tests__/ProductFilters.test.tsx +56 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/components/ProductCard.tsx +78 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/components/ProductFilters.tsx +104 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/pages/ProductDetailPage.tsx +179 -0
- package/templates/ecommerce/apps/web/src/modules/catalog/pages/ProductListPage.tsx +100 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/__tests__/CheckoutPage.test.tsx +159 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/__tests__/StripePaymentForm.test.tsx +79 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/components/StripePaymentForm.tsx +55 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/hooks/useCheckout.ts +56 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/pages/CheckoutPage.tsx +344 -0
- package/templates/ecommerce/apps/web/src/modules/checkout/pages/CheckoutSuccessPage.tsx +12 -0
- package/templates/ecommerce/apps/web/src/modules/legal/pages/PrivacyPolicyPage.tsx +207 -0
- package/templates/ecommerce/apps/web/src/modules/legal/pages/TermsOfServicePage.tsx +175 -0
- package/templates/ecommerce/apps/web/src/modules/orders/__tests__/OrderDetailPage.test.tsx +75 -0
- package/templates/ecommerce/apps/web/src/modules/orders/__tests__/OrderHistoryPage.test.tsx +87 -0
- package/templates/ecommerce/apps/web/src/modules/orders/pages/OrderDetailPage.tsx +73 -0
- package/templates/ecommerce/apps/web/src/modules/orders/pages/OrderHistoryPage.tsx +97 -0
- package/templates/ecommerce/apps/web/src/modules/profile/__tests__/ProfilePage.test.tsx +150 -0
- package/templates/ecommerce/apps/web/src/modules/profile/pages/ProfilePage.tsx +275 -0
- package/templates/ecommerce/apps/web/src/setupTests.ts +10 -0
- package/templates/ecommerce/apps/web/src/shared/components/CookieConsent.tsx +108 -0
- package/templates/ecommerce/apps/web/src/shared/components/ErrorBoundary.tsx +112 -0
- package/templates/ecommerce/apps/web/src/shared/components/Layout.tsx +143 -0
- package/templates/ecommerce/apps/web/src/shared/components/ProtectedRoute.tsx +21 -0
- package/templates/ecommerce/apps/web/src/shared/config/siteConfig.ts +57 -0
- package/templates/ecommerce/apps/web/src/shared/hooks/usePageTitle.ts +16 -0
- package/templates/ecommerce/apps/web/src/shared/lib/apiFetch.ts +16 -0
- package/templates/ecommerce/apps/web/src/shared/pages/NotFoundPage.tsx +42 -0
- package/templates/ecommerce/apps/web/src/shared/theme/ThemeProvider.tsx +45 -0
- package/templates/ecommerce/apps/web/src/shared/theme/__tests__/ThemeProvider.test.tsx +78 -0
- package/templates/ecommerce/apps/web/src/shared/theme/createTheme.ts +58 -0
- package/templates/ecommerce/apps/web/src/shared/theme/tokens.ts +81 -0
- package/templates/ecommerce/apps/web/src/vite-env.d.ts +1 -0
- package/templates/ecommerce/apps/web/tsconfig.jest.json +12 -0
- package/templates/ecommerce/apps/web/tsconfig.json +25 -0
- package/templates/ecommerce/apps/web/tsconfig.node.json +11 -0
- package/templates/ecommerce/apps/web/vite.config.ts +30 -0
- package/templates/ecommerce/docker-compose.yml +85 -0
- package/templates/ecommerce/package-lock.json +11255 -0
- package/templates/ecommerce/package.json +27 -0
- package/templates/ecommerce/packages/shared-types/package.json +13 -0
- package/templates/ecommerce/packages/shared-types/src/index.ts +3 -0
- package/templates/ecommerce/packages/shared-types/src/theme.ts +44 -0
- package/templates/ecommerce/packages/shared-types/tsconfig.json +11 -0
- package/templates/ecommerce/scripts/customize.sh +201 -0
- package/templates/ecommerce/tsconfig.json +14 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { IOrderRepository } from '../../domain/checkout/order.repository';
|
|
2
|
+
import type { OrderStatus } from '../../domain/checkout/order.entity';
|
|
3
|
+
|
|
4
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
export interface StatsResult {
|
|
6
|
+
totalRevenue: number;
|
|
7
|
+
totalOrders: number;
|
|
8
|
+
totalCustomers: number;
|
|
9
|
+
avgOrderValue: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TopProduct {
|
|
13
|
+
productId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
totalQty: number;
|
|
16
|
+
totalRevenue: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
20
|
+
const REVENUE_STATUSES: OrderStatus[] = ['PAID', 'SHIPPED', 'DELIVERED'];
|
|
21
|
+
|
|
22
|
+
function buildRange(
|
|
23
|
+
period?: string,
|
|
24
|
+
from?: string,
|
|
25
|
+
to?: string,
|
|
26
|
+
): { start: Date; end: Date } | null {
|
|
27
|
+
if (!period) return null;
|
|
28
|
+
const now = new Date();
|
|
29
|
+
if (period === 'today') {
|
|
30
|
+
const start = new Date(now);
|
|
31
|
+
start.setHours(0, 0, 0, 0);
|
|
32
|
+
return { start, end: now };
|
|
33
|
+
}
|
|
34
|
+
if (period === 'week') {
|
|
35
|
+
const start = new Date(now);
|
|
36
|
+
start.setDate(start.getDate() - 7);
|
|
37
|
+
return { start, end: now };
|
|
38
|
+
}
|
|
39
|
+
if (period === 'month') {
|
|
40
|
+
const start = new Date(now);
|
|
41
|
+
start.setDate(start.getDate() - 30);
|
|
42
|
+
return { start, end: now };
|
|
43
|
+
}
|
|
44
|
+
if (period === 'custom' && from && to) {
|
|
45
|
+
return { start: new Date(from), end: new Date(to) };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── DashboardService ──────────────────────────────────────────────────────────
|
|
51
|
+
export class DashboardService {
|
|
52
|
+
constructor(private readonly orderRepo: IOrderRepository) {}
|
|
53
|
+
|
|
54
|
+
async getStats(
|
|
55
|
+
opts: { period?: string; from?: string; to?: string } = {},
|
|
56
|
+
): Promise<StatsResult> {
|
|
57
|
+
const { items: all } = await this.orderRepo.findAll({ limit: 10_000 });
|
|
58
|
+
const range = buildRange(opts.period, opts.from, opts.to);
|
|
59
|
+
const orders = range
|
|
60
|
+
? all.filter((o) => o.createdAt >= range.start && o.createdAt <= range.end)
|
|
61
|
+
: all;
|
|
62
|
+
|
|
63
|
+
const revenue = orders.filter((o) => REVENUE_STATUSES.includes(o.status));
|
|
64
|
+
const totalRevenue = revenue.reduce((s, o) => s + o.total, 0);
|
|
65
|
+
const totalOrders = orders.length;
|
|
66
|
+
const totalCustomers = new Set(revenue.map((o) => o.userId)).size;
|
|
67
|
+
const avgOrderValue = revenue.length > 0 ? totalRevenue / revenue.length : 0;
|
|
68
|
+
|
|
69
|
+
return { totalRevenue, totalOrders, totalCustomers, avgOrderValue };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async getTopProducts(
|
|
73
|
+
opts: { period?: string; from?: string; to?: string; limit?: number } = {},
|
|
74
|
+
): Promise<TopProduct[]> {
|
|
75
|
+
const { items: all } = await this.orderRepo.findAll({ limit: 10_000 });
|
|
76
|
+
const range = buildRange(opts.period, opts.from, opts.to);
|
|
77
|
+
const orders = range
|
|
78
|
+
? all.filter((o) => o.createdAt >= range.start && o.createdAt <= range.end)
|
|
79
|
+
: all;
|
|
80
|
+
|
|
81
|
+
const revenue = orders.filter((o) => REVENUE_STATUSES.includes(o.status));
|
|
82
|
+
const map = new Map<string, { name: string; totalQty: number; totalRevenue: number }>();
|
|
83
|
+
|
|
84
|
+
for (const order of revenue) {
|
|
85
|
+
for (const item of order.items) {
|
|
86
|
+
const e = map.get(item.productId) ?? {
|
|
87
|
+
name: item.name,
|
|
88
|
+
totalQty: 0,
|
|
89
|
+
totalRevenue: 0,
|
|
90
|
+
};
|
|
91
|
+
e.totalQty += item.qty;
|
|
92
|
+
e.totalRevenue += item.price * item.qty;
|
|
93
|
+
map.set(item.productId, e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [...map.entries()]
|
|
98
|
+
.map(([productId, d]) => ({ productId, ...d }))
|
|
99
|
+
.sort((a, b) => b.totalQty - a.totalQty)
|
|
100
|
+
.slice(0, opts.limit ?? 10);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { hash, compare } from 'bcryptjs';
|
|
2
|
+
import { IUserRepository } from '../../domain/auth/user.repository';
|
|
3
|
+
import { ITokenService, TokenPayload } from '../ports/token.port';
|
|
4
|
+
import { UserEntity, PublicUser } from '../../domain/auth/user.entity';
|
|
5
|
+
import { AppError } from '../../domain/shared/AppError';
|
|
6
|
+
import { IEmailService } from '../ports/email.port';
|
|
7
|
+
|
|
8
|
+
const BCRYPT_ROUNDS = 10;
|
|
9
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
10
|
+
|
|
11
|
+
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
export interface RegisterDto {
|
|
13
|
+
name: string;
|
|
14
|
+
email: string;
|
|
15
|
+
password: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LoginDto {
|
|
19
|
+
email: string;
|
|
20
|
+
password: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AuthTokens {
|
|
24
|
+
accessToken: string;
|
|
25
|
+
refreshToken: string;
|
|
26
|
+
user: PublicUser;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ForgotPasswordDto {
|
|
30
|
+
email: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResetPasswordDto {
|
|
34
|
+
token: string;
|
|
35
|
+
newPassword: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Service ───────────────────────────────────────────────────────────────────
|
|
39
|
+
export class AuthService {
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly userRepository: IUserRepository,
|
|
42
|
+
private readonly tokenService: ITokenService,
|
|
43
|
+
private readonly emailService: IEmailService,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
// ── register ─────────────────────────────────────────────────────────────
|
|
47
|
+
async register(dto: RegisterDto): Promise<PublicUser> {
|
|
48
|
+
if (dto.password.length < MIN_PASSWORD_LENGTH) {
|
|
49
|
+
throw new AppError(`Senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres`, 422);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existing = await this.userRepository.findByEmail(dto.email);
|
|
53
|
+
if (existing) {
|
|
54
|
+
throw new AppError('Email já cadastrado', 409);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const passwordHash = await hash(dto.password, BCRYPT_ROUNDS);
|
|
58
|
+
const user = UserEntity.create({ name: dto.name, email: dto.email, passwordHash });
|
|
59
|
+
const saved = await this.userRepository.create(user);
|
|
60
|
+
|
|
61
|
+
return saved.toPublic();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── login ─────────────────────────────────────────────────────────────────
|
|
65
|
+
async login(dto: LoginDto): Promise<AuthTokens> {
|
|
66
|
+
const user = await this.userRepository.findByEmail(dto.email);
|
|
67
|
+
if (!user) {
|
|
68
|
+
throw new AppError('Credenciais inválidas', 401);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const passwordMatch = await compare(dto.password, user.passwordHash);
|
|
72
|
+
if (!passwordMatch) {
|
|
73
|
+
throw new AppError('Credenciais inválidas', 401);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const payload: TokenPayload = { sub: user.id, email: user.email, role: user.role };
|
|
77
|
+
const accessToken = this.tokenService.generateAccessToken(payload);
|
|
78
|
+
const refreshToken = this.tokenService.generateRefreshToken(payload);
|
|
79
|
+
|
|
80
|
+
return { accessToken, refreshToken, user: user.toPublic() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── refreshToken ──────────────────────────────────────────────────────────
|
|
84
|
+
async refreshToken(token: string): Promise<{ accessToken: string }> {
|
|
85
|
+
const blacklisted = await this.tokenService.isRefreshTokenBlacklisted(token);
|
|
86
|
+
if (blacklisted) {
|
|
87
|
+
throw new AppError('Refresh token inválido', 401);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let payload: TokenPayload;
|
|
91
|
+
try {
|
|
92
|
+
payload = this.tokenService.verifyRefreshToken(token);
|
|
93
|
+
} catch {
|
|
94
|
+
throw new AppError('Refresh token inválido ou expirado', 401);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const user = await this.userRepository.findById(payload.sub);
|
|
98
|
+
if (!user) {
|
|
99
|
+
throw new AppError('Usuário não encontrado', 401);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const accessToken = this.tokenService.generateAccessToken({
|
|
103
|
+
sub: user.id,
|
|
104
|
+
email: user.email,
|
|
105
|
+
role: user.role,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return { accessToken };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── logout ────────────────────────────────────────────────────────────────
|
|
112
|
+
async logout(refreshToken: string): Promise<void> {
|
|
113
|
+
await this.tokenService.invalidateRefreshToken(refreshToken);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── forgotPassword ────────────────────────────────────────────────────────
|
|
117
|
+
async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
|
|
118
|
+
const user = await this.userRepository.findByEmail(dto.email);
|
|
119
|
+
|
|
120
|
+
// Always return success — prevents user enumeration
|
|
121
|
+
if (!user) return;
|
|
122
|
+
|
|
123
|
+
const resetToken = await this.tokenService.generatePasswordResetToken(user.id);
|
|
124
|
+
const resetUrl = `${process.env['FRONTEND_URL'] ?? 'http://localhost:5174'}/reset-password?token=${resetToken}`;
|
|
125
|
+
const html = buildPasswordResetEmail(user.name, resetUrl);
|
|
126
|
+
await this.emailService.send(user.email, 'Recuperação de senha', html);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── resetPassword ─────────────────────────────────────────────────────────
|
|
130
|
+
async resetPassword(dto: ResetPasswordDto): Promise<void> {
|
|
131
|
+
if (dto.newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
132
|
+
throw new AppError(`Senha deve ter pelo menos ${MIN_PASSWORD_LENGTH} caracteres`, 422);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let userId: string;
|
|
136
|
+
try {
|
|
137
|
+
userId = await this.tokenService.verifyPasswordResetToken(dto.token);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
if (err instanceof AppError) throw err;
|
|
140
|
+
throw new AppError('Token de recuperação inválido ou expirado', 400);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const user = await this.userRepository.findById(userId);
|
|
144
|
+
if (!user) {
|
|
145
|
+
throw new AppError('Usuário não encontrado', 400);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const newHash = await hash(dto.newPassword, BCRYPT_ROUNDS);
|
|
149
|
+
const updated = user.withPasswordHash(newHash);
|
|
150
|
+
await this.userRepository.update(updated);
|
|
151
|
+
await this.tokenService.invalidatePasswordResetToken(dto.token);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Email template ────────────────────────────────────────────────────────────
|
|
156
|
+
function escapeHtml(text: string): string {
|
|
157
|
+
return text
|
|
158
|
+
.replace(/&/g, '&')
|
|
159
|
+
.replace(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildPasswordResetEmail(name: string, resetUrl: string): string {
|
|
165
|
+
return `
|
|
166
|
+
<!DOCTYPE html>
|
|
167
|
+
<html lang="pt-BR">
|
|
168
|
+
<head><meta charset="UTF-8"></head>
|
|
169
|
+
<body style="font-family: Arial, sans-serif; background: #f4f4f4; padding: 32px;">
|
|
170
|
+
<div style="max-width: 520px; margin: 0 auto; background: #fff; border-radius: 8px; padding: 32px;">
|
|
171
|
+
<h2 style="color: #1a1a2e; margin-top: 0;">Recuperação de senha</h2>
|
|
172
|
+
<p>Olá, <strong>${escapeHtml(name)}</strong>!</p>
|
|
173
|
+
<p>Você solicitou a recuperação de senha. Clique no botão abaixo para redefinir:</p>
|
|
174
|
+
<p style="text-align: center; margin: 32px 0;">
|
|
175
|
+
<a href="${resetUrl}"
|
|
176
|
+
style="background:#6366f1;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:bold;">
|
|
177
|
+
Redefinir senha
|
|
178
|
+
</a>
|
|
179
|
+
</p>
|
|
180
|
+
<p style="font-size:13px;color:#555;">Este link expira em 1 hora. Se você não solicitou isso, ignore este email.</p>
|
|
181
|
+
</div>
|
|
182
|
+
</body>
|
|
183
|
+
</html>
|
|
184
|
+
`.trim();
|
|
185
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { AppError } from '../../domain/shared/AppError';
|
|
2
|
+
import { CartEntity, CartItemProps } from '../../domain/cart/cart.entity';
|
|
3
|
+
import { ICartRepository } from '../../domain/cart/cart.repository';
|
|
4
|
+
import { ICouponRepository } from '../../domain/cart/coupon.repository';
|
|
5
|
+
import { IShippingService, ShippingOption } from '../ports/shipping.port';
|
|
6
|
+
import { IProductRepository } from '../../domain/catalog/product.repository';
|
|
7
|
+
|
|
8
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
9
|
+
export interface CartKey {
|
|
10
|
+
userId?: string;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AddItemDto {
|
|
15
|
+
productId: string;
|
|
16
|
+
variantId: string;
|
|
17
|
+
qty: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── CartService ───────────────────────────────────────────────────────────────
|
|
21
|
+
export class CartService {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly cartRepo: ICartRepository,
|
|
24
|
+
private readonly couponRepo: ICouponRepository,
|
|
25
|
+
private readonly shippingService: IShippingService,
|
|
26
|
+
private readonly productRepo: IProductRepository,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
// ── getCart ───────────────────────────────────────────────────────────────
|
|
30
|
+
async getCart(key: CartKey): Promise<CartEntity> {
|
|
31
|
+
const existing = await this._find(key);
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
return CartEntity.create(key.userId ?? null, key.sessionId ?? null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private async _find(key: CartKey): Promise<CartEntity | null> {
|
|
37
|
+
if (key.userId) return this.cartRepo.findByUserId(key.userId);
|
|
38
|
+
if (key.sessionId) return this.cartRepo.findBySessionId(key.sessionId);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async _findProductByVariantId(variantId: string) {
|
|
43
|
+
const all = await this.productRepo.findAll();
|
|
44
|
+
return all.find((p) => p.variants.some((v) => v.id === variantId)) ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── addItem ───────────────────────────────────────────────────────────────
|
|
48
|
+
async addItem(key: CartKey, dto: AddItemDto): Promise<CartEntity> {
|
|
49
|
+
if (dto.qty <= 0) throw new AppError('Quantidade deve ser maior que zero', 400);
|
|
50
|
+
|
|
51
|
+
const product = await this.productRepo.findById(dto.productId);
|
|
52
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
53
|
+
|
|
54
|
+
const variant = product.variants.find((v) => v.id === dto.variantId);
|
|
55
|
+
if (!variant) throw new AppError('Variante não encontrada', 404);
|
|
56
|
+
|
|
57
|
+
// Stock check: existing cart qty + new qty must not exceed stock
|
|
58
|
+
let cart = await this.getCart(key);
|
|
59
|
+
const existingItem = cart.items.find((i) => i.variantId === dto.variantId);
|
|
60
|
+
const currentQty = existingItem?.qty ?? 0;
|
|
61
|
+
if (currentQty + dto.qty > variant.stock) {
|
|
62
|
+
throw new AppError('Estoque insuficiente', 409);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const price = variant.price !== null && variant.price !== undefined
|
|
66
|
+
? Number(variant.price)
|
|
67
|
+
: Number(product.price);
|
|
68
|
+
|
|
69
|
+
const item: CartItemProps = {
|
|
70
|
+
variantId: dto.variantId,
|
|
71
|
+
productId: dto.productId,
|
|
72
|
+
name: product.name,
|
|
73
|
+
sku: variant.sku,
|
|
74
|
+
price,
|
|
75
|
+
qty: dto.qty,
|
|
76
|
+
image: product.images[0] ?? null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
cart = cart.addItem(item);
|
|
80
|
+
return this.cartRepo.save(cart);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── updateItem ────────────────────────────────────────────────────────────
|
|
84
|
+
async updateItem(key: CartKey, variantId: string, qty: number): Promise<CartEntity> {
|
|
85
|
+
if (qty < 0) throw new AppError('Quantidade não pode ser negativa', 400);
|
|
86
|
+
|
|
87
|
+
let cart = await this.getCart(key);
|
|
88
|
+
|
|
89
|
+
if (qty > 0) {
|
|
90
|
+
const product = await this._findProductByVariantId(variantId);
|
|
91
|
+
const variant = product?.variants.find((v) => v.id === variantId);
|
|
92
|
+
if (variant && qty > variant.stock) {
|
|
93
|
+
throw new AppError('Estoque insuficiente', 409);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cart = cart.updateItem(variantId, qty);
|
|
98
|
+
return this.cartRepo.save(cart);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── removeItem ────────────────────────────────────────────────────────────
|
|
102
|
+
async removeItem(key: CartKey, variantId: string): Promise<CartEntity> {
|
|
103
|
+
const cart = (await this.getCart(key)).removeItem(variantId);
|
|
104
|
+
return this.cartRepo.save(cart);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── applyCoupon ───────────────────────────────────────────────────────────
|
|
108
|
+
async applyCoupon(key: CartKey, code: string): Promise<CartEntity> {
|
|
109
|
+
const coupon = await this.couponRepo.findByCode(code);
|
|
110
|
+
if (!coupon) throw new AppError('Cupom não encontrado', 404);
|
|
111
|
+
|
|
112
|
+
let cart = await this.getCart(key);
|
|
113
|
+
cart = cart.applyCoupon(coupon); // throws 400 if expired / minimum not met
|
|
114
|
+
return this.cartRepo.save(cart);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── getShippingOptions ────────────────────────────────────────────────────
|
|
118
|
+
async getShippingOptions(key: CartKey, cep: string): Promise<ShippingOption[]> {
|
|
119
|
+
const cart = await this.getCart(key);
|
|
120
|
+
const items = cart.items.map((i) => ({ qty: i.qty }));
|
|
121
|
+
return this.shippingService.calculate(cep, items);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── mergeAnonymousCart ────────────────────────────────────────────────────
|
|
125
|
+
async mergeAnonymousCart(userId: string, sessionId: string): Promise<CartEntity> {
|
|
126
|
+
const [anonCart, userCart] = await Promise.all([
|
|
127
|
+
this.cartRepo.findBySessionId(sessionId),
|
|
128
|
+
this.cartRepo.findByUserId(userId),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
if (!anonCart) {
|
|
132
|
+
return userCart ?? CartEntity.create(userId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let merged = userCart ?? CartEntity.create(userId);
|
|
136
|
+
for (const item of anonCart.items) {
|
|
137
|
+
merged = merged.addItem({ ...item });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (anonCart.coupon) {
|
|
141
|
+
try {
|
|
142
|
+
merged = merged.applyCoupon(anonCart.coupon);
|
|
143
|
+
} catch {
|
|
144
|
+
// Ignore coupon errors during merge
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await this.cartRepo.delete({ sessionId });
|
|
149
|
+
return this.cartRepo.save(merged);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppError } from '../../domain/shared/AppError';
|
|
3
|
+
import { CouponEntity, DiscountType } from '../../domain/cart/coupon.entity';
|
|
4
|
+
import { ICouponRepository } from '../../domain/cart/coupon.repository';
|
|
5
|
+
|
|
6
|
+
// ── Validation schema ─────────────────────────────────────────────────────────
|
|
7
|
+
export const createCouponSchema = z.object({
|
|
8
|
+
code: z.string().min(1).max(50).toUpperCase(),
|
|
9
|
+
discountType: z.enum(['percent', 'fixed'] as [DiscountType, ...DiscountType[]]),
|
|
10
|
+
discountValue: z.number().positive({ message: 'discountValue must be > 0' }),
|
|
11
|
+
minOrderValue: z.number().min(0).default(0),
|
|
12
|
+
usageLimit: z.number().int().positive().nullable().default(null),
|
|
13
|
+
expiresAt: z
|
|
14
|
+
.string()
|
|
15
|
+
.datetime()
|
|
16
|
+
.nullable()
|
|
17
|
+
.optional()
|
|
18
|
+
.transform((v) => (v ? new Date(v) : null))
|
|
19
|
+
.default(null),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type CreateCouponDTO = z.infer<typeof createCouponSchema>;
|
|
23
|
+
|
|
24
|
+
// ── CouponAdminService ────────────────────────────────────────────────────────
|
|
25
|
+
export class CouponAdminService {
|
|
26
|
+
constructor(private readonly couponRepo: ICouponRepository) {}
|
|
27
|
+
|
|
28
|
+
async create(dto: CreateCouponDTO): Promise<CouponEntity> {
|
|
29
|
+
const existing = await this.couponRepo.findByCode(dto.code);
|
|
30
|
+
if (existing) {
|
|
31
|
+
throw new AppError(`Coupon code "${dto.code}" already exists`, 409);
|
|
32
|
+
}
|
|
33
|
+
const coupon = CouponEntity.create({
|
|
34
|
+
code: dto.code,
|
|
35
|
+
discountType: dto.discountType,
|
|
36
|
+
discountValue: dto.discountValue,
|
|
37
|
+
minOrderValue: dto.minOrderValue,
|
|
38
|
+
usageLimit: dto.usageLimit ?? null,
|
|
39
|
+
expiresAt: dto.expiresAt ?? null,
|
|
40
|
+
});
|
|
41
|
+
return this.couponRepo.save(coupon);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async list(): Promise<CouponEntity[]> {
|
|
45
|
+
return this.couponRepo.findAll();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async delete(id: string): Promise<void> {
|
|
49
|
+
await this.couponRepo.deleteById(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { ICategoryRepository } from '../../domain/catalog/category.repository';
|
|
3
|
+
import { IProductRepository, SearchParams, SearchResult } from '../../domain/catalog/product.repository';
|
|
4
|
+
import { ProductEntity } from '../../domain/catalog/product.entity';
|
|
5
|
+
import { AppError } from '../../domain/shared/AppError';
|
|
6
|
+
import { IStorageService } from '../ports/storage.port';
|
|
7
|
+
|
|
8
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
9
|
+
function toSlug(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.normalize('NFD')
|
|
13
|
+
.replace(/[\u0300-\u036f]/g, '') // strip accents
|
|
14
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/\s+/g, '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
|
20
|
+
export interface CreateProductDto {
|
|
21
|
+
name: string;
|
|
22
|
+
description?: string | null;
|
|
23
|
+
price: number;
|
|
24
|
+
categoryId?: string;
|
|
25
|
+
categorySlug?: string;
|
|
26
|
+
images?: string[];
|
|
27
|
+
variants: Array<{
|
|
28
|
+
sku: string;
|
|
29
|
+
size?: string | null;
|
|
30
|
+
color?: string | null;
|
|
31
|
+
stock: number;
|
|
32
|
+
price?: number | null;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UpdateProductDto {
|
|
37
|
+
name?: string;
|
|
38
|
+
description?: string | null;
|
|
39
|
+
price?: number;
|
|
40
|
+
images?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UpdateStockDto {
|
|
44
|
+
productId: string;
|
|
45
|
+
variantId: string;
|
|
46
|
+
delta: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Service ───────────────────────────────────────────────────────────────────
|
|
50
|
+
export class CatalogService {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly categoryRepository: ICategoryRepository,
|
|
53
|
+
private readonly productRepository: IProductRepository,
|
|
54
|
+
private readonly storageService?: IStorageService,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
// ── createProduct ─────────────────────────────────────────────────────────
|
|
58
|
+
async createProduct(dto: CreateProductDto): Promise<ProductEntity> {
|
|
59
|
+
// Validate price eagerly before category lookup
|
|
60
|
+
if (dto.price < 0) {
|
|
61
|
+
throw new AppError('Preço não pode ser negativo', 422);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve category (accepts either categoryId or categorySlug)
|
|
65
|
+
let categoryId = dto.categoryId;
|
|
66
|
+
if (!categoryId && dto.categorySlug) {
|
|
67
|
+
const category = await this.categoryRepository.findBySlug(dto.categorySlug);
|
|
68
|
+
if (!category) throw new AppError(`Categoria '${dto.categorySlug}' não encontrada`, 404);
|
|
69
|
+
categoryId = category.id;
|
|
70
|
+
} else if (categoryId) {
|
|
71
|
+
const category = await this.categoryRepository.findById(categoryId);
|
|
72
|
+
if (!category) throw new AppError('Categoria não encontrada', 404);
|
|
73
|
+
} else {
|
|
74
|
+
throw new AppError('Categoria é obrigatória', 422);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Generate unique slug
|
|
78
|
+
const baseSlug = toSlug(dto.name);
|
|
79
|
+
let slug = baseSlug;
|
|
80
|
+
let attempt = 0;
|
|
81
|
+
while (await this.productRepository.slugExists(slug)) {
|
|
82
|
+
attempt++;
|
|
83
|
+
slug = `${baseSlug}-${attempt}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const product = ProductEntity.create({
|
|
87
|
+
name: dto.name,
|
|
88
|
+
slug,
|
|
89
|
+
description: dto.description,
|
|
90
|
+
price: dto.price,
|
|
91
|
+
categoryId,
|
|
92
|
+
images: dto.images,
|
|
93
|
+
variants: dto.variants,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return this.productRepository.create(product);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── updateProduct ─────────────────────────────────────────────────────────
|
|
100
|
+
async updateProduct(id: string, dto: UpdateProductDto): Promise<ProductEntity> {
|
|
101
|
+
const product = await this.productRepository.findById(id);
|
|
102
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
103
|
+
|
|
104
|
+
if (dto.price !== undefined && dto.price < 0) {
|
|
105
|
+
throw new AppError('Preço não pode ser negativo', 422);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const updated = product.withUpdates(dto);
|
|
109
|
+
return this.productRepository.update(updated);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── deleteProduct ─────────────────────────────────────────────────────────
|
|
113
|
+
async deleteProduct(id: string): Promise<undefined> {
|
|
114
|
+
const product = await this.productRepository.findById(id);
|
|
115
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
116
|
+
return this.productRepository.softDelete(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── getProductBySlug ──────────────────────────────────────────────────────
|
|
120
|
+
async getProductBySlug(slug: string): Promise<ProductEntity> {
|
|
121
|
+
const product = await this.productRepository.findBySlug(slug);
|
|
122
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
123
|
+
return product;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── updateStock ───────────────────────────────────────────────────────────
|
|
127
|
+
async updateStock(dto: UpdateStockDto): Promise<{ variantId: string; stock: number }> {
|
|
128
|
+
const product = await this.productRepository.findById(dto.productId);
|
|
129
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
130
|
+
|
|
131
|
+
const variant = product.variants.find((v) => v.id === dto.variantId);
|
|
132
|
+
if (!variant) throw new AppError('Variação não encontrada', 404);
|
|
133
|
+
|
|
134
|
+
const newStock = variant.stock + dto.delta;
|
|
135
|
+
if (newStock < 0) {
|
|
136
|
+
throw new AppError('Estoque insuficiente', 409);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await this.productRepository.updateVariantStock(dto.productId, dto.variantId, newStock);
|
|
140
|
+
return { variantId: dto.variantId, stock: newStock };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── searchProducts ────────────────────────────────────────────────────────
|
|
144
|
+
async searchProducts(params: SearchParams): Promise<SearchResult> {
|
|
145
|
+
return this.productRepository.search(params);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Category helpers ──────────────────────────────────────────────────────
|
|
149
|
+
async listCategories() {
|
|
150
|
+
return this.categoryRepository.findAll();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── uploadProductImage ────────────────────────────────────────────────────
|
|
154
|
+
async uploadProductImage(productId: string, buffer: Buffer, mimetype: string): Promise<ProductEntity> {
|
|
155
|
+
if (!this.storageService) {
|
|
156
|
+
throw new AppError('Storage service not configured', 500);
|
|
157
|
+
}
|
|
158
|
+
const product = await this.productRepository.findById(productId);
|
|
159
|
+
if (!product) throw new AppError('Produto não encontrado', 404);
|
|
160
|
+
|
|
161
|
+
const ext = mimetype.split('/')[1] ?? 'jpg';
|
|
162
|
+
const key = `products/${productId}/${randomUUID()}.${ext}`;
|
|
163
|
+
const url = await this.storageService.upload(key, buffer, mimetype);
|
|
164
|
+
|
|
165
|
+
const updated = product.withUpdates({ images: [...product.images, url] });
|
|
166
|
+
return this.productRepository.update(updated);
|
|
167
|
+
}
|
|
168
|
+
}
|