From bb59d5e9ba11c1c126e3c54b03584806a10ffc79 Mon Sep 17 00:00:00 2001 From: Ali Reza Date: Tue, 21 Jul 2026 17:52:36 +0330 Subject: [PATCH] Initial commit: Meshkee CMS API NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM. --- .cursor/rules/business-rbac.mdc | 34 + .cursor/rules/database-migrations.mdc | 33 + .cursor/rules/meshkee-project.mdc | 35 + .cursor/rules/nestjs-patterns.mdc | 46 + .env.example | 44 + .gitignore | 5 + .idea/.gitignore | 8 + .idea/MeshkeeApp Backend.iml | 12 + .idea/modules.xml | 8 + database/migrate.sh | 27 + database/migrations/001_initial_schema.sql | 102 + .../002_phone_permissions_content_media.sql | 324 + database/migrations/003_categories.sql | 79 + .../004_user_types_and_business_members.sql | 128 + .../migrations/005_business_team_roles.sql | 79 + .../migrations/006_business_categories.sql | 75 + database/migrations/006_user_profile.sql | 2 + .../migrations/007_business_i18n_fields.sql | 9 + .../migrations/007_domain_expiry_active.sql | 9 + database/migrations/008_remove_name_en.sql | 7 + .../migrations/009_categories_name_fa.sql | 4 + .../migrations/010_category_variations.sql | 104 + .../011_category_technical_forms.sql | 118 + database/migrations/012_comments.sql | 83 + database/migrations/013_expert_reviews.sql | 83 + database/migrations/014_addresses.sql | 38 + database/migrations/015_business_profile.sql | 12 + database/migrations/015_cities.sql | 73 + .../016_product_variation_values.sql | 20 + .../017_product_variant_festival.sql | 3 + .../018_product_variant_reward_points.sql | 7 + database/migrations/019_cart_and_orders.sql | 205 + .../020_store_items_and_variants.sql | 198 + .../021_business_customer_is_enabled.sql | 6 + database/migrations/022_transactions.sql | 114 + .../migrations/023_orders_process_step.sql | 7 + database/migrations/024_shopping_cards.sql | 63 + database/migrations/025_blogs_post_type.sql | 9 + database/migrations/026_store_specials.sql | 37 + .../migrations/027_contact_submissions.sql | 27 + database/migrations/028_favorites.sql | 42 + database/migrations/029_brands.sql | 66 + database/migrations/030_website_homepage.sql | 177 + database/migrations/031_address_label.sql | 4 + .../032_address_postal_code_optional.sql | 7 + database/migrations/033_business_favicon.sql | 17 + database/seed.sh | 27 + database/seeds/001_sample_data.sql | 183 + database/seeds/002_super_admin_user.sql | 35 + .../seeds/003_comments_and_expert_reviews.sql | 181 + database/seeds/004_iran_cities.sql | 113 + database/seeds/005_business_categories.sql | 178 + database/setup.sh | 9 + database/wait-for-postgres.sh | 29 + docker-compose.prod.yml | 12 + docker-compose.yml | 38 + docs/DEPLOY.md | 178 + docs/PROJECT_CONTEXT.md | 601 ++ ecosystem.config.js | 16 + nest-cli.json | 8 + package-lock.json | 6487 +++++++++++++++++ package.json | 44 + .../Meshkee-CMS-Auth.postman_collection.json | 2644 +++++++ ...eshkee-Website-API.postman_collection.json | 1667 +++++ prisma/schema.prisma | 1125 +++ src/app.module.ts | 67 + src/auth/auth.controller.ts | 108 + src/auth/auth.module.ts | 42 + src/auth/auth.service.ts | 467 ++ src/auth/auth.types.ts | 98 + src/auth/decorators/current-user.decorator.ts | 9 + .../require-business-permission.decorator.ts | 6 + src/auth/dto/change-password.dto.ts | 10 + src/auth/dto/login.dto.ts | 13 + src/auth/dto/refresh-token.dto.ts | 6 + src/auth/dto/register.dto.ts | 30 + src/auth/dto/send-otp.dto.ts | 9 + src/auth/dto/update-profile.dto.ts | 63 + src/auth/dto/upsert-user-address.dto.ts | 33 + src/auth/dto/verify-otp.dto.ts | 14 + src/auth/guards/business-permission.guard.ts | 53 + src/auth/guards/jwt-auth.guard.ts | 5 + src/auth/permissions.service.ts | 98 + src/auth/profile.util.ts | 18 + src/auth/sms.service.ts | 37 + src/auth/strategies/jwt.strategy.ts | 82 + src/auth/user-addresses.service.ts | 109 + src/blogs/blogs.controller.ts | 120 + src/blogs/blogs.module.ts | 13 + src/blogs/blogs.service.ts | 733 ++ src/blogs/dto/blog.dto.ts | 170 + src/brands/brands.controller.ts | 75 + src/brands/brands.module.ts | 12 + src/brands/brands.service.ts | 284 + src/brands/dto/brand.dto.ts | 86 + .../business-admin.controller.ts | 94 + src/business-admin/business-admin.module.ts | 14 + src/business-admin/business-admin.service.ts | 646 ++ .../business-categories.controller.ts | 16 + .../business-categories.service.ts | 40 + src/business-admin/dto/add-domain.dto.ts | 12 + src/business-admin/dto/create-business.dto.ts | 55 + .../dto/disable-business.dto.ts | 7 + src/business-admin/dto/list-businesses.dto.ts | 30 + .../dto/search-businesses.dto.ts | 12 + src/business-admin/dto/update-business.dto.ts | 47 + src/business-admin/dto/update-domain.dto.ts | 7 + .../business-profile.controller.ts | 30 + .../business-profile.module.ts | 11 + .../business-profile.service.ts | 510 ++ .../business-profile.types.ts | 48 + src/business-profile/business-profile.util.ts | 58 + .../dto/update-business-profile.dto.ts | 124 + .../business-primary-colors.ts | 98 + .../business-settings.controller.ts | 30 + .../business-settings.module.ts | 12 + .../business-settings.service.ts | 158 + .../business-settings.types.ts | 60 + .../business-settings.util.ts | 119 + .../dto/update-business-settings.dto.ts | 86 + src/business-settings/order-step-colors.ts | 56 + src/business-team/business-team.controller.ts | 70 + src/business-team/business-team.module.ts | 11 + src/business-team/business-team.service.ts | 284 + src/business-team/dto/add-team-member.dto.ts | 29 + .../dto/update-team-member.dto.ts | 8 + src/cart/cart.controller.ts | 75 + src/cart/cart.module.ts | 12 + src/cart/cart.service.ts | 404 + src/cart/dto/cart.dto.ts | 73 + src/categories/categories.controller.ts | 176 + src/categories/categories.module.ts | 23 + src/categories/categories.service.ts | 320 + src/categories/category-ai.service.ts | 280 + .../category-technical-form-ai.service.ts | 184 + .../category-technical-form.service.ts | 253 + src/categories/category-variations.service.ts | 234 + src/categories/color-presets.ts | 27 + src/categories/dto/category-ai.dto.ts | 7 + .../dto/category-technical-form.dto.ts | 48 + src/categories/dto/category-variation.dto.ts | 31 + src/categories/dto/category.dto.ts | 77 + src/cities/cities.controller.ts | 18 + src/cities/cities.module.ts | 10 + src/cities/cities.service.ts | 100 + src/cities/dto/list-cities.dto.ts | 19 + src/comments/comments.controller.ts | 75 + src/comments/comments.module.ts | 13 + src/comments/comments.service.ts | 258 + src/comments/dto/comment.dto.ts | 79 + src/common/ai-provider.util.ts | 83 + .../bigint-serializer.interceptor.ts | 36 + .../contact-submissions.controller.ts | 45 + .../contact-submissions.module.ts | 15 + .../contact-submissions.service.ts | 155 + .../dto/create-contact-submission.dto.ts | 27 + .../dto/list-contact-submissions.dto.ts | 20 + src/customers/customers.controller.ts | 78 + src/customers/customers.module.ts | 11 + src/customers/customers.service.ts | 402 + src/customers/dto/create-customer.dto.ts | 32 + src/customers/dto/list-customers.dto.ts | 24 + src/customers/dto/search-customers.dto.ts | 12 + src/customers/dto/update-customer.dto.ts | 26 + src/domain-admin/domain-admin.controller.ts | 56 + src/domain-admin/domain-admin.module.ts | 11 + src/domain-admin/domain-admin.service.ts | 150 + src/domain-admin/dto/disable-domain.dto.ts | 6 + src/domain-admin/dto/list-domains.dto.ts | 21 + src/domain-admin/dto/toggle-ssl.dto.ts | 6 + .../dto/update-domain-admin.dto.ts | 12 + src/expert-reviews/dto/expert-review.dto.ts | 100 + .../expert-reviews.controller.ts | 75 + src/expert-reviews/expert-reviews.module.ts | 16 + src/expert-reviews/expert-reviews.service.ts | 242 + src/favorites/dto/favorite.dto.ts | 22 + src/favorites/favorites.controller.ts | 48 + src/favorites/favorites.module.ts | 11 + src/favorites/favorites.service.ts | 351 + src/main.ts | 25 + src/media/dto/list-media.dto.ts | 22 + src/media/dto/update-media.dto.ts | 13 + src/media/media.controller.ts | 75 + src/media/media.module.ts | 11 + src/media/media.service.ts | 374 + src/orders/dto/order.dto.ts | 169 + src/orders/orders.controller.ts | 80 + src/orders/orders.module.ts | 13 + src/orders/orders.service.ts | 922 +++ src/portfolios/dto/portfolio.dto.ts | 168 + src/portfolios/portfolios.controller.ts | 123 + src/portfolios/portfolios.module.ts | 16 + src/portfolios/portfolios.service.ts | 832 +++ src/prisma/prisma.module.ts | 9 + src/prisma/prisma.service.ts | 16 + src/products/dto/product-ai.dto.ts | 19 + .../dto/product-technical-info.dto.ts | 24 + .../dto/product-variation-values.dto.ts | 24 + src/products/dto/product.dto.ts | 170 + src/products/product-ai.service.ts | 257 + .../product-technical-info.service.ts | 394 + .../product-variation-values.service.ts | 251 + src/products/products.controller.ts | 192 + src/products/products.module.ts | 23 + src/products/products.service.ts | 810 ++ src/redis/redis.constants.ts | 1 + src/redis/redis.module.ts | 21 + src/redis/redis.service.ts | 24 + src/roles/dto/list-roles.dto.ts | 7 + src/roles/roles.controller.ts | 17 + src/roles/roles.module.ts | 11 + src/roles/roles.service.ts | 66 + src/shopping-cards/dto/shopping-card.dto.ts | 73 + .../shopping-cards.controller.ts | 66 + src/shopping-cards/shopping-cards.module.ts | 12 + src/shopping-cards/shopping-cards.service.ts | 420 ++ src/storage/s3-storage.driver.ts | 85 + src/storage/storage.module.ts | 10 + src/storage/storage.service.ts | 28 + src/storage/storage.types.ts | 11 + src/store/dto/store-items.dto.ts | 223 + src/store/dto/store-specials.dto.ts | 71 + src/store/store-items.controller.ts | 133 + src/store/store-items.service.ts | 1088 +++ src/store/store-specials.controller.ts | 89 + src/store/store-specials.service.ts | 425 ++ src/store/store.module.ts | 23 + src/tenant/tenant.controller.ts | 13 + src/tenant/tenant.module.ts | 10 + src/tenant/tenant.service.ts | 63 + src/transactions/dto/transaction.dto.ts | 37 + src/users/dto/admin-reset-password.dto.ts | 7 + src/users/dto/create-user.dto.ts | 38 + src/users/dto/list-users.dto.ts | 39 + src/users/dto/search-users.dto.ts | 12 + src/users/dto/send-user-message.dto.ts | 7 + src/users/dto/update-user-role.dto.ts | 8 + src/users/dto/update-user.dto.ts | 22 + src/users/users.controller.ts | 91 + src/users/users.module.ts | 11 + src/users/users.service.ts | 502 ++ src/website/dto/website-brand-groups.dto.ts | 71 + .../dto/website-category-groups.dto.ts | 71 + src/website/dto/website-sliders.dto.ts | 95 + .../website-brand-groups.controller.ts | 89 + src/website/website-brand-groups.service.ts | 295 + .../website-business-info.controller.ts | 12 + src/website/website-business-info.service.ts | 55 + .../website-category-groups.controller.ts | 89 + .../website-category-groups.service.ts | 294 + src/website/website-sliders.controller.ts | 89 + src/website/website-sliders.service.ts | 303 + src/website/website.module.ts | 42 + tsconfig.json | 24 + 254 files changed, 37031 insertions(+) create mode 100644 .cursor/rules/business-rbac.mdc create mode 100644 .cursor/rules/database-migrations.mdc create mode 100644 .cursor/rules/meshkee-project.mdc create mode 100644 .cursor/rules/nestjs-patterns.mdc create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/MeshkeeApp Backend.iml create mode 100644 .idea/modules.xml create mode 100755 database/migrate.sh create mode 100644 database/migrations/001_initial_schema.sql create mode 100644 database/migrations/002_phone_permissions_content_media.sql create mode 100644 database/migrations/003_categories.sql create mode 100644 database/migrations/004_user_types_and_business_members.sql create mode 100644 database/migrations/005_business_team_roles.sql create mode 100644 database/migrations/006_business_categories.sql create mode 100644 database/migrations/006_user_profile.sql create mode 100644 database/migrations/007_business_i18n_fields.sql create mode 100644 database/migrations/007_domain_expiry_active.sql create mode 100644 database/migrations/008_remove_name_en.sql create mode 100644 database/migrations/009_categories_name_fa.sql create mode 100644 database/migrations/010_category_variations.sql create mode 100644 database/migrations/011_category_technical_forms.sql create mode 100644 database/migrations/012_comments.sql create mode 100644 database/migrations/013_expert_reviews.sql create mode 100644 database/migrations/014_addresses.sql create mode 100644 database/migrations/015_business_profile.sql create mode 100644 database/migrations/015_cities.sql create mode 100644 database/migrations/016_product_variation_values.sql create mode 100644 database/migrations/017_product_variant_festival.sql create mode 100644 database/migrations/018_product_variant_reward_points.sql create mode 100644 database/migrations/019_cart_and_orders.sql create mode 100644 database/migrations/020_store_items_and_variants.sql create mode 100644 database/migrations/021_business_customer_is_enabled.sql create mode 100644 database/migrations/022_transactions.sql create mode 100644 database/migrations/023_orders_process_step.sql create mode 100644 database/migrations/024_shopping_cards.sql create mode 100644 database/migrations/025_blogs_post_type.sql create mode 100644 database/migrations/026_store_specials.sql create mode 100644 database/migrations/027_contact_submissions.sql create mode 100644 database/migrations/028_favorites.sql create mode 100644 database/migrations/029_brands.sql create mode 100644 database/migrations/030_website_homepage.sql create mode 100644 database/migrations/031_address_label.sql create mode 100644 database/migrations/032_address_postal_code_optional.sql create mode 100644 database/migrations/033_business_favicon.sql create mode 100755 database/seed.sh create mode 100644 database/seeds/001_sample_data.sql create mode 100644 database/seeds/002_super_admin_user.sql create mode 100644 database/seeds/003_comments_and_expert_reviews.sql create mode 100644 database/seeds/004_iran_cities.sql create mode 100644 database/seeds/005_business_categories.sql create mode 100755 database/setup.sh create mode 100755 database/wait-for-postgres.sh create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/DEPLOY.md create mode 100644 docs/PROJECT_CONTEXT.md create mode 100644 ecosystem.config.js create mode 100644 nest-cli.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postman/Meshkee-CMS-Auth.postman_collection.json create mode 100644 postman/Meshkee-Website-API.postman_collection.json create mode 100644 prisma/schema.prisma create mode 100644 src/app.module.ts create mode 100644 src/auth/auth.controller.ts create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/auth.types.ts create mode 100644 src/auth/decorators/current-user.decorator.ts create mode 100644 src/auth/decorators/require-business-permission.decorator.ts create mode 100644 src/auth/dto/change-password.dto.ts create mode 100644 src/auth/dto/login.dto.ts create mode 100644 src/auth/dto/refresh-token.dto.ts create mode 100644 src/auth/dto/register.dto.ts create mode 100644 src/auth/dto/send-otp.dto.ts create mode 100644 src/auth/dto/update-profile.dto.ts create mode 100644 src/auth/dto/upsert-user-address.dto.ts create mode 100644 src/auth/dto/verify-otp.dto.ts create mode 100644 src/auth/guards/business-permission.guard.ts create mode 100644 src/auth/guards/jwt-auth.guard.ts create mode 100644 src/auth/permissions.service.ts create mode 100644 src/auth/profile.util.ts create mode 100644 src/auth/sms.service.ts create mode 100644 src/auth/strategies/jwt.strategy.ts create mode 100644 src/auth/user-addresses.service.ts create mode 100644 src/blogs/blogs.controller.ts create mode 100644 src/blogs/blogs.module.ts create mode 100644 src/blogs/blogs.service.ts create mode 100644 src/blogs/dto/blog.dto.ts create mode 100644 src/brands/brands.controller.ts create mode 100644 src/brands/brands.module.ts create mode 100644 src/brands/brands.service.ts create mode 100644 src/brands/dto/brand.dto.ts create mode 100644 src/business-admin/business-admin.controller.ts create mode 100644 src/business-admin/business-admin.module.ts create mode 100644 src/business-admin/business-admin.service.ts create mode 100644 src/business-admin/business-categories.controller.ts create mode 100644 src/business-admin/business-categories.service.ts create mode 100644 src/business-admin/dto/add-domain.dto.ts create mode 100644 src/business-admin/dto/create-business.dto.ts create mode 100644 src/business-admin/dto/disable-business.dto.ts create mode 100644 src/business-admin/dto/list-businesses.dto.ts create mode 100644 src/business-admin/dto/search-businesses.dto.ts create mode 100644 src/business-admin/dto/update-business.dto.ts create mode 100644 src/business-admin/dto/update-domain.dto.ts create mode 100644 src/business-profile/business-profile.controller.ts create mode 100644 src/business-profile/business-profile.module.ts create mode 100644 src/business-profile/business-profile.service.ts create mode 100644 src/business-profile/business-profile.types.ts create mode 100644 src/business-profile/business-profile.util.ts create mode 100644 src/business-profile/dto/update-business-profile.dto.ts create mode 100644 src/business-settings/business-primary-colors.ts create mode 100644 src/business-settings/business-settings.controller.ts create mode 100644 src/business-settings/business-settings.module.ts create mode 100644 src/business-settings/business-settings.service.ts create mode 100644 src/business-settings/business-settings.types.ts create mode 100644 src/business-settings/business-settings.util.ts create mode 100644 src/business-settings/dto/update-business-settings.dto.ts create mode 100644 src/business-settings/order-step-colors.ts create mode 100644 src/business-team/business-team.controller.ts create mode 100644 src/business-team/business-team.module.ts create mode 100644 src/business-team/business-team.service.ts create mode 100644 src/business-team/dto/add-team-member.dto.ts create mode 100644 src/business-team/dto/update-team-member.dto.ts create mode 100644 src/cart/cart.controller.ts create mode 100644 src/cart/cart.module.ts create mode 100644 src/cart/cart.service.ts create mode 100644 src/cart/dto/cart.dto.ts create mode 100644 src/categories/categories.controller.ts create mode 100644 src/categories/categories.module.ts create mode 100644 src/categories/categories.service.ts create mode 100644 src/categories/category-ai.service.ts create mode 100644 src/categories/category-technical-form-ai.service.ts create mode 100644 src/categories/category-technical-form.service.ts create mode 100644 src/categories/category-variations.service.ts create mode 100644 src/categories/color-presets.ts create mode 100644 src/categories/dto/category-ai.dto.ts create mode 100644 src/categories/dto/category-technical-form.dto.ts create mode 100644 src/categories/dto/category-variation.dto.ts create mode 100644 src/categories/dto/category.dto.ts create mode 100644 src/cities/cities.controller.ts create mode 100644 src/cities/cities.module.ts create mode 100644 src/cities/cities.service.ts create mode 100644 src/cities/dto/list-cities.dto.ts create mode 100644 src/comments/comments.controller.ts create mode 100644 src/comments/comments.module.ts create mode 100644 src/comments/comments.service.ts create mode 100644 src/comments/dto/comment.dto.ts create mode 100644 src/common/ai-provider.util.ts create mode 100644 src/common/interceptors/bigint-serializer.interceptor.ts create mode 100644 src/contact-submissions/contact-submissions.controller.ts create mode 100644 src/contact-submissions/contact-submissions.module.ts create mode 100644 src/contact-submissions/contact-submissions.service.ts create mode 100644 src/contact-submissions/dto/create-contact-submission.dto.ts create mode 100644 src/contact-submissions/dto/list-contact-submissions.dto.ts create mode 100644 src/customers/customers.controller.ts create mode 100644 src/customers/customers.module.ts create mode 100644 src/customers/customers.service.ts create mode 100644 src/customers/dto/create-customer.dto.ts create mode 100644 src/customers/dto/list-customers.dto.ts create mode 100644 src/customers/dto/search-customers.dto.ts create mode 100644 src/customers/dto/update-customer.dto.ts create mode 100644 src/domain-admin/domain-admin.controller.ts create mode 100644 src/domain-admin/domain-admin.module.ts create mode 100644 src/domain-admin/domain-admin.service.ts create mode 100644 src/domain-admin/dto/disable-domain.dto.ts create mode 100644 src/domain-admin/dto/list-domains.dto.ts create mode 100644 src/domain-admin/dto/toggle-ssl.dto.ts create mode 100644 src/domain-admin/dto/update-domain-admin.dto.ts create mode 100644 src/expert-reviews/dto/expert-review.dto.ts create mode 100644 src/expert-reviews/expert-reviews.controller.ts create mode 100644 src/expert-reviews/expert-reviews.module.ts create mode 100644 src/expert-reviews/expert-reviews.service.ts create mode 100644 src/favorites/dto/favorite.dto.ts create mode 100644 src/favorites/favorites.controller.ts create mode 100644 src/favorites/favorites.module.ts create mode 100644 src/favorites/favorites.service.ts create mode 100644 src/main.ts create mode 100644 src/media/dto/list-media.dto.ts create mode 100644 src/media/dto/update-media.dto.ts create mode 100644 src/media/media.controller.ts create mode 100644 src/media/media.module.ts create mode 100644 src/media/media.service.ts create mode 100644 src/orders/dto/order.dto.ts create mode 100644 src/orders/orders.controller.ts create mode 100644 src/orders/orders.module.ts create mode 100644 src/orders/orders.service.ts create mode 100644 src/portfolios/dto/portfolio.dto.ts create mode 100644 src/portfolios/portfolios.controller.ts create mode 100644 src/portfolios/portfolios.module.ts create mode 100644 src/portfolios/portfolios.service.ts create mode 100644 src/prisma/prisma.module.ts create mode 100644 src/prisma/prisma.service.ts create mode 100644 src/products/dto/product-ai.dto.ts create mode 100644 src/products/dto/product-technical-info.dto.ts create mode 100644 src/products/dto/product-variation-values.dto.ts create mode 100644 src/products/dto/product.dto.ts create mode 100644 src/products/product-ai.service.ts create mode 100644 src/products/product-technical-info.service.ts create mode 100644 src/products/product-variation-values.service.ts create mode 100644 src/products/products.controller.ts create mode 100644 src/products/products.module.ts create mode 100644 src/products/products.service.ts create mode 100644 src/redis/redis.constants.ts create mode 100644 src/redis/redis.module.ts create mode 100644 src/redis/redis.service.ts create mode 100644 src/roles/dto/list-roles.dto.ts create mode 100644 src/roles/roles.controller.ts create mode 100644 src/roles/roles.module.ts create mode 100644 src/roles/roles.service.ts create mode 100644 src/shopping-cards/dto/shopping-card.dto.ts create mode 100644 src/shopping-cards/shopping-cards.controller.ts create mode 100644 src/shopping-cards/shopping-cards.module.ts create mode 100644 src/shopping-cards/shopping-cards.service.ts create mode 100644 src/storage/s3-storage.driver.ts create mode 100644 src/storage/storage.module.ts create mode 100644 src/storage/storage.service.ts create mode 100644 src/storage/storage.types.ts create mode 100644 src/store/dto/store-items.dto.ts create mode 100644 src/store/dto/store-specials.dto.ts create mode 100644 src/store/store-items.controller.ts create mode 100644 src/store/store-items.service.ts create mode 100644 src/store/store-specials.controller.ts create mode 100644 src/store/store-specials.service.ts create mode 100644 src/store/store.module.ts create mode 100644 src/tenant/tenant.controller.ts create mode 100644 src/tenant/tenant.module.ts create mode 100644 src/tenant/tenant.service.ts create mode 100644 src/transactions/dto/transaction.dto.ts create mode 100644 src/users/dto/admin-reset-password.dto.ts create mode 100644 src/users/dto/create-user.dto.ts create mode 100644 src/users/dto/list-users.dto.ts create mode 100644 src/users/dto/search-users.dto.ts create mode 100644 src/users/dto/send-user-message.dto.ts create mode 100644 src/users/dto/update-user-role.dto.ts create mode 100644 src/users/dto/update-user.dto.ts create mode 100644 src/users/users.controller.ts create mode 100644 src/users/users.module.ts create mode 100644 src/users/users.service.ts create mode 100644 src/website/dto/website-brand-groups.dto.ts create mode 100644 src/website/dto/website-category-groups.dto.ts create mode 100644 src/website/dto/website-sliders.dto.ts create mode 100644 src/website/website-brand-groups.controller.ts create mode 100644 src/website/website-brand-groups.service.ts create mode 100644 src/website/website-business-info.controller.ts create mode 100644 src/website/website-business-info.service.ts create mode 100644 src/website/website-category-groups.controller.ts create mode 100644 src/website/website-category-groups.service.ts create mode 100644 src/website/website-sliders.controller.ts create mode 100644 src/website/website-sliders.service.ts create mode 100644 src/website/website.module.ts create mode 100644 tsconfig.json diff --git a/.cursor/rules/business-rbac.mdc b/.cursor/rules/business-rbac.mdc new file mode 100644 index 0000000..671e712 --- /dev/null +++ b/.cursor/rules/business-rbac.mdc @@ -0,0 +1,34 @@ +--- +description: Business-scoped RBAC and permission patterns +globs: src/**/*.controller.ts,src/**/*.service.ts +alwaysApply: false +--- + +# Business RBAC + +## Permission slugs + +Format: `resource.action` — e.g. `products.read`, `categories.update`, `business.team.invite` + +Content resources: `products.*`, `categories.*`, `media.*`, `business.team.*` + +## Who gets access + +- `super_admin` → all permissions (bypasses business guard) +- Business owner (`isOwner=true`) → `business_owner` role permissions +- Team member → permissions from `business_users.role_id` (admin/editor/viewer) + +## Adding a business-scoped endpoint + +1. Route: `businesses/:businessId/` +2. `@UseGuards(JwtAuthGuard, BusinessPermissionGuard)` +3. `@RequireBusinessPermission('resource.action')` on handler +4. `assertPermission()` again inside the service + +## Platform-only endpoints + +Super-admin routes (`/users`, `/businesses`, `/domains`) check `permissions.isSuperAdmin()` in the service — no `BusinessPermissionGuard`. + +## New permissions + +Add `INSERT INTO permissions` + `role_permissions` in a SQL migration, then assign to relevant team roles. diff --git a/.cursor/rules/database-migrations.mdc b/.cursor/rules/database-migrations.mdc new file mode 100644 index 0000000..e9fbd00 --- /dev/null +++ b/.cursor/rules/database-migrations.mdc @@ -0,0 +1,33 @@ +--- +description: SQL migration and Prisma schema workflow +globs: database/**/*,prisma/**/* +alwaysApply: false +--- + +# Database Workflow + +## Migrations + +- Raw SQL in `database/migrations/` — numbered files (e.g. `012_feature.sql`) +- Apply: `./database/migrate.sh` or `docker exec` into `meshkee-postgres` +- Docker auto-runs migrations only on **first** Postgres volume init + +## After schema change + +```bash +npm run prisma:pull +npm run prisma:generate +``` + +Never edit `prisma/schema.prisma` without a corresponding SQL migration (except post-pull formatting). + +## Conventions in SQL + +- `set_updated_at()` triggers on mutable tables +- `BIGINT GENERATED BY DEFAULT AS IDENTITY` for PKs +- Foreign keys with explicit `ON DELETE` (Cascade for owned data, Restrict/SetNull where appropriate) +- Seed permissions in the same migration when adding new resources + +## Prisma relations + +Keep relation names aligned with existing schema style. `businessId` maps to `business_id`, enums use `@@map` for snake_case DB names. diff --git a/.cursor/rules/meshkee-project.mdc b/.cursor/rules/meshkee-project.mdc new file mode 100644 index 0000000..7882f74 --- /dev/null +++ b/.cursor/rules/meshkee-project.mdc @@ -0,0 +1,35 @@ +--- +description: Meshkee CMS API project context and architecture essentials +alwaysApply: true +--- + +# Meshkee CMS API + +Read `docs/PROJECT_CONTEXT.md` for full reference before large changes. + +## Stack + +NestJS 11 + TypeScript + Prisma 6 + PostgreSQL 16 + Redis + S3 (Parmin). + +API prefix: `/api/v1`. Package name: `meshkee-cms-api`. + +## Architecture + +- Multi-tenant: `Business` is the tenant root; routes are `businesses/:businessId/...` +- **Content Category** (`categories`) ≠ **Business Category** (`business_categories`) — do not confuse them +- **Location Cities** (`cities`) — system reference tree (country → province → city) for address forms; not business-scoped. Distinct from **Addresses** (`addresses`) which store user/business street addresses +- Blogs/portfolios exist in DB + permissions but have no Prisma models or API modules yet + +## Schema changes + +1. Add SQL file in `database/migrations/` +2. Apply via `./database/migrate.sh` +3. Run `npm run prisma:pull` then `npm run prisma:generate` + +Do **not** use Prisma Migrate. SQL migrations are authoritative. + +## Scope discipline + +- Minimize diff scope; match existing module patterns +- Reuse existing services/guards instead of reimplementing +- No commits unless explicitly requested diff --git a/.cursor/rules/nestjs-patterns.mdc b/.cursor/rules/nestjs-patterns.mdc new file mode 100644 index 0000000..6d52577 --- /dev/null +++ b/.cursor/rules/nestjs-patterns.mdc @@ -0,0 +1,46 @@ +--- +description: NestJS module, controller, service, and DTO conventions +globs: src/**/*.ts +alwaysApply: false +--- + +# NestJS Module Pattern + +Each feature: `*.module.ts` → `*.controller.ts` → `*.service.ts` → `dto/` + +## Controllers + +```typescript +@Controller('businesses/:businessId/products') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class ProductsController { + @Get() + @RequireBusinessPermission('products.read') + list(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.list(businessId, user); + } +} +``` + +## Services + +- Convert route IDs with `BigInt(idRaw)` +- Inject `PrismaService` + `PermissionsService` +- Add private `assertPermission(businessId, userId, 'slug')` (defense in depth) +- Use `$transaction` for multi-step writes +- Private `serialize()` methods; return IDs as strings + +## DTOs + +- `class-validator` on all request bodies and query params +- `@Type(() => Number)` for query coercion +- Slug: `^[a-z0-9]+(?:-[a-z0-9]+)*$` +- Cell: E.164 `^\+[1-9]\d{6,14}$` + +## Nested replace resources + +For sub-resources like variations or technical forms: `GET` returns state, `PUT` validates → delete-all → recreate in transaction. See `CategoryVariationsService`. + +## Errors + +Use Nest exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`, `ConflictException`. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7bd1fb2 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# PostgreSQL (DataGrip + API) +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=meshkee +POSTGRES_PASSWORD=meshkee_secret +POSTGRES_DB=meshkee_cms + +DATABASE_URL=postgresql://meshkee:meshkee_secret@localhost:5432/meshkee_cms + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_URL=redis://localhost:6379 + +# API +PORT=3000 + +# JWT +JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars-long +JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars-long +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# SMS (set to true when SMS provider API is ready) +SMS_ENABLED=false + +# Object storage (Parmin / S3-compatible) +STORAGE_DISK=s3 +S3_ENDPOINT=https://sas.amin.parminstorage.ir +S3_BUCKET=meshkee-storage +S3_PUBLIC_URL=https://meshkee-storage.sas.amin.parminstorage.ir +S3_REGION=us-east-1 +S3_FORCE_PATH_STYLE=true +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= + +# AI product generation (use Groq free tier or OpenAI) +AI_PROVIDER=groq +GROQ_API_KEY= +GROQ_MODEL=llama-3.3-70b-versatile +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini + +MEDIA_MAX_FILE_SIZE_MB=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1ec73a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +node_modules/ +dist/ +.DS_Store +*.log diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/MeshkeeApp Backend.iml b/.idea/MeshkeeApp Backend.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/MeshkeeApp Backend.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e2a1337 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/database/migrate.sh b/database/migrate.sh new file mode 100755 index 0000000..b144600 --- /dev/null +++ b/database/migrate.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIGRATIONS_DIR="$ROOT_DIR/database/migrations" +CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}" +DB_USER="${POSTGRES_USER:-meshkee}" +DB_NAME="${POSTGRES_DB:-meshkee_cms}" + +"$ROOT_DIR/database/wait-for-postgres.sh" + +run_migration() { + local file="$1" + echo "→ Running $(basename "$file")" + docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file" +} + +if [[ $# -gt 0 ]]; then + run_migration "$1" +else + for file in "$MIGRATIONS_DIR"/*.sql; do + [[ -f "$file" ]] || continue + run_migration "$file" + done +fi + +echo "Done." diff --git a/database/migrations/001_initial_schema.sql b/database/migrations/001_initial_schema.sql new file mode 100644 index 0000000..0f39606 --- /dev/null +++ b/database/migrations/001_initial_schema.sql @@ -0,0 +1,102 @@ +-- Meshkee CMS — initial schema +-- Tables: users, businesses, domains + +-- Reusable trigger to keep updated_at in sync +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + cell_number VARCHAR(20) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(255), + first_name VARCHAR(100), + last_name VARCHAR(100), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + cell_verified_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT users_cell_number_unique UNIQUE (cell_number), + CONSTRAINT users_cell_number_format CHECK (cell_number ~ '^\+[1-9]\d{6,14}$'), + CONSTRAINT users_email_format_optional CHECK ( + email IS NULL OR email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' + ) +); + +CREATE INDEX idx_users_cell_number ON users (cell_number); +CREATE INDEX idx_users_is_active ON users (is_active) WHERE is_active = TRUE; + +CREATE TRIGGER users_set_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- businesses (created by super admin — no direct user owner column) +-- --------------------------------------------------------------------------- +CREATE TABLE businesses ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + settings JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT businesses_slug_unique UNIQUE (slug), + CONSTRAINT businesses_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$') +); + +CREATE INDEX idx_businesses_is_active ON businesses (is_active) WHERE is_active = TRUE; + +CREATE TRIGGER businesses_set_updated_at + BEFORE UPDATE ON businesses + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- domains (many per business) +-- --------------------------------------------------------------------------- +CREATE TABLE domains ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + host VARCHAR(253) NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + is_verified BOOLEAN NOT NULL DEFAULT FALSE, + verified_at TIMESTAMPTZ, + ssl_enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT domains_host_unique UNIQUE (host), + CONSTRAINT domains_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT domains_host_format CHECK ( + host ~ '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$' + OR host ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' + ) +); + +CREATE INDEX idx_domains_business_id ON domains (business_id); +CREATE INDEX idx_domains_host ON domains (host); +CREATE INDEX idx_domains_business_primary ON domains (business_id) WHERE is_primary = TRUE; + +CREATE UNIQUE INDEX idx_domains_one_primary_per_business + ON domains (business_id) + WHERE is_primary = TRUE; + +CREATE TRIGGER domains_set_updated_at + BEFORE UPDATE ON domains + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/database/migrations/002_phone_permissions_content_media.sql b/database/migrations/002_phone_permissions_content_media.sql new file mode 100644 index 0000000..4f26805 --- /dev/null +++ b/database/migrations/002_phone_permissions_content_media.sql @@ -0,0 +1,324 @@ +-- Meshkee CMS — RBAC, content tables, media + +-- --------------------------------------------------------------------------- +-- enums +-- --------------------------------------------------------------------------- +CREATE TYPE content_status AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE media_type AS ENUM ('image', 'video'); +CREATE TYPE media_entity_type AS ENUM ('product', 'blog', 'portfolio'); + +-- --------------------------------------------------------------------------- +-- permissions (RBAC) +-- --------------------------------------------------------------------------- +CREATE TABLE permissions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + group_name VARCHAR(50) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT permissions_slug_unique UNIQUE (slug) +); + +CREATE TABLE roles ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + is_system BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT roles_slug_unique UNIQUE (slug) +); + +CREATE TABLE role_permissions ( + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (role_id, permission_id), + CONSTRAINT role_permissions_role_id_fkey + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE, + CONSTRAINT role_permissions_permission_id_fkey + FOREIGN KEY (permission_id) REFERENCES permissions (id) ON DELETE CASCADE +); + +CREATE TABLE user_roles ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT user_roles_user_role_unique UNIQUE (user_id, role_id), + CONSTRAINT user_roles_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT user_roles_role_id_fkey + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE +); + +CREATE INDEX idx_user_roles_user_id ON user_roles (user_id); +CREATE INDEX idx_role_permissions_permission_id ON role_permissions (permission_id); + +CREATE TRIGGER roles_set_updated_at + BEFORE UPDATE ON roles + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- media (images & videos, scoped per business) +-- --------------------------------------------------------------------------- +CREATE TABLE media ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + uploaded_by BIGINT, + media_type media_type NOT NULL, + storage_disk VARCHAR(50) NOT NULL DEFAULT 'local', + storage_path TEXT NOT NULL, + public_url TEXT NOT NULL, + file_name VARCHAR(255) NOT NULL, + original_file_name VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_size_bytes BIGINT NOT NULL, + width INTEGER, + height INTEGER, + duration_seconds NUMERIC(10, 2), + alt_text VARCHAR(255), + caption TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT media_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT media_uploaded_by_fkey + FOREIGN KEY (uploaded_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT media_file_size_positive CHECK (file_size_bytes > 0), + CONSTRAINT media_image_dimensions CHECK ( + media_type <> 'image' OR (width IS NOT NULL AND height IS NOT NULL) + ), + CONSTRAINT media_video_duration CHECK ( + media_type <> 'video' OR duration_seconds IS NOT NULL + ) +); + +CREATE INDEX idx_media_business_id ON media (business_id); +CREATE INDEX idx_media_business_type ON media (business_id, media_type); +CREATE INDEX idx_media_created_at ON media (business_id, created_at DESC); + +CREATE TRIGGER media_set_updated_at + BEFORE UPDATE ON media + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- products +-- --------------------------------------------------------------------------- +CREATE TABLE products ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + content JSONB NOT NULL DEFAULT '{}', + price NUMERIC(12, 2), + compare_at_price NUMERIC(12, 2), + sku VARCHAR(100), + stock_quantity INTEGER, + status content_status NOT NULL DEFAULT 'draft', + featured_media_id BIGINT, + sort_order INTEGER NOT NULL DEFAULT 0, + published_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT products_business_slug_unique UNIQUE (business_id, slug), + CONSTRAINT products_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT products_featured_media_id_fkey + FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL, + CONSTRAINT products_price_non_negative CHECK (price IS NULL OR price >= 0), + CONSTRAINT products_compare_price_non_negative CHECK (compare_at_price IS NULL OR compare_at_price >= 0), + CONSTRAINT products_stock_non_negative CHECK (stock_quantity IS NULL OR stock_quantity >= 0) +); + +CREATE INDEX idx_products_business_id ON products (business_id); +CREATE INDEX idx_products_business_status ON products (business_id, status); +CREATE INDEX idx_products_business_published ON products (business_id, published_at DESC); + +CREATE TRIGGER products_set_updated_at + BEFORE UPDATE ON products + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- blogs +-- --------------------------------------------------------------------------- +CREATE TABLE blogs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + author_id BIGINT, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + excerpt TEXT, + content JSONB NOT NULL DEFAULT '{}', + status content_status NOT NULL DEFAULT 'draft', + featured_media_id BIGINT, + published_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT blogs_business_slug_unique UNIQUE (business_id, slug), + CONSTRAINT blogs_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT blogs_author_id_fkey + FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT blogs_featured_media_id_fkey + FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL +); + +CREATE INDEX idx_blogs_business_id ON blogs (business_id); +CREATE INDEX idx_blogs_business_status ON blogs (business_id, status); +CREATE INDEX idx_blogs_business_published ON blogs (business_id, published_at DESC); + +CREATE TRIGGER blogs_set_updated_at + BEFORE UPDATE ON blogs + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- portfolios +-- --------------------------------------------------------------------------- +CREATE TABLE portfolios ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + content JSONB NOT NULL DEFAULT '{}', + client_name VARCHAR(255), + project_url TEXT, + status content_status NOT NULL DEFAULT 'draft', + featured_media_id BIGINT, + sort_order INTEGER NOT NULL DEFAULT 0, + published_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT portfolios_business_slug_unique UNIQUE (business_id, slug), + CONSTRAINT portfolios_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT portfolios_featured_media_id_fkey + FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL +); + +CREATE INDEX idx_portfolios_business_id ON portfolios (business_id); +CREATE INDEX idx_portfolios_business_status ON portfolios (business_id, status); +CREATE INDEX idx_portfolios_business_published ON portfolios (business_id, published_at DESC); + +CREATE TRIGGER portfolios_set_updated_at + BEFORE UPDATE ON portfolios + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- media attachments (galleries, inline images/videos on content) +-- --------------------------------------------------------------------------- +CREATE TABLE media_attachments ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + media_id BIGINT NOT NULL, + entity_type media_entity_type NOT NULL, + entity_id BIGINT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT media_attachments_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT media_attachments_media_id_fkey + FOREIGN KEY (media_id) REFERENCES media (id) ON DELETE CASCADE, + CONSTRAINT media_attachments_unique UNIQUE (media_id, entity_type, entity_id) +); + +CREATE INDEX idx_media_attachments_entity + ON media_attachments (business_id, entity_type, entity_id, sort_order); +CREATE INDEX idx_media_attachments_media_id ON media_attachments (media_id); + +-- --------------------------------------------------------------------------- +-- seed: default permissions & roles +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View business', 'business.read', 'business', 'View business profile and settings'), + ('Update business', 'business.update', 'business', 'Edit business profile and settings'), + ('View domains', 'domains.read', 'domains', 'View connected domains'), + ('Manage domains', 'domains.manage', 'domains', 'Add, edit, and remove domains'), + ('View products', 'products.read', 'products', 'View products'), + ('Create products', 'products.create', 'products', 'Create products'), + ('Update products', 'products.update', 'products', 'Edit products'), + ('Delete products', 'products.delete', 'products', 'Delete products'), + ('Publish products', 'products.publish', 'products', 'Publish and unpublish products'), + ('View blogs', 'blogs.read', 'blogs', 'View blog posts'), + ('Create blogs', 'blogs.create', 'blogs', 'Create blog posts'), + ('Update blogs', 'blogs.update', 'blogs', 'Edit blog posts'), + ('Delete blogs', 'blogs.delete', 'blogs', 'Delete blog posts'), + ('Publish blogs', 'blogs.publish', 'blogs', 'Publish and unpublish blog posts'), + ('View portfolios', 'portfolios.read', 'portfolios', 'View portfolio items'), + ('Create portfolios', 'portfolios.create', 'portfolios', 'Create portfolio items'), + ('Update portfolios', 'portfolios.update', 'portfolios', 'Edit portfolio items'), + ('Delete portfolios', 'portfolios.delete', 'portfolios', 'Delete portfolio items'), + ('Publish portfolios', 'portfolios.publish', 'portfolios', 'Publish and unpublish portfolio items'), + ('View media', 'media.read', 'media', 'View uploaded media'), + ('Upload media', 'media.create', 'media', 'Upload images and videos'), + ('Update media', 'media.update', 'media', 'Edit media metadata'), + ('Delete media', 'media.delete', 'media', 'Delete media files'), + ('View users', 'users.read', 'users', 'View user accounts'), + ('Manage users', 'users.manage', 'users', 'Create and manage user accounts'), + ('Manage roles', 'roles.manage', 'users', 'Assign roles and permissions'); + +INSERT INTO roles (name, slug, description, is_system) VALUES + ('Owner', 'owner', 'Full access to everything', TRUE), + ('Administrator', 'admin', 'Manage content, media, and settings', TRUE), + ('Editor', 'editor', 'Create and edit content', TRUE), + ('Viewer', 'viewer', 'Read-only access', TRUE); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.slug = 'owner'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug <> 'roles.manage' +WHERE r.slug = 'admin'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ( + 'business.read', + 'products.read', 'products.create', 'products.update', 'products.publish', + 'blogs.read', 'blogs.create', 'blogs.update', 'blogs.publish', + 'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.publish', + 'media.read', 'media.create', 'media.update' +) +WHERE r.slug = 'editor'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ( + 'business.read', + 'domains.read', + 'products.read', + 'blogs.read', + 'portfolios.read', + 'media.read' +) +WHERE r.slug = 'viewer'; diff --git a/database/migrations/003_categories.sql b/database/migrations/003_categories.sql new file mode 100644 index 0000000..e123af2 --- /dev/null +++ b/database/migrations/003_categories.sql @@ -0,0 +1,79 @@ +-- Meshkee CMS — categories for products, blogs, portfolios + +CREATE TABLE categories ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + entity_type media_entity_type NOT NULL, + parent_id BIGINT, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT categories_business_entity_slug_unique + UNIQUE (business_id, entity_type, slug), + CONSTRAINT categories_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT categories_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL, + CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$') +); + +CREATE INDEX idx_categories_business_entity + ON categories (business_id, entity_type, sort_order); +CREATE INDEX idx_categories_parent_id ON categories (parent_id); +CREATE INDEX idx_categories_active + ON categories (business_id, entity_type) WHERE is_active = TRUE; + +CREATE TRIGGER categories_set_updated_at + BEFORE UPDATE ON categories + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE category_assignments ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + entity_type media_entity_type NOT NULL, + entity_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_assignments_unique + UNIQUE (category_id, entity_type, entity_id), + CONSTRAINT category_assignments_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT category_assignments_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE +); + +CREATE INDEX idx_category_assignments_entity + ON category_assignments (business_id, entity_type, entity_id); +CREATE INDEX idx_category_assignments_category_id + ON category_assignments (category_id); + +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View categories', 'categories.read', 'categories', 'View categories'), + ('Create categories', 'categories.create', 'categories', 'Create categories'), + ('Update categories', 'categories.update', 'categories', 'Edit categories'), + ('Delete categories', 'categories.delete', 'categories', 'Delete categories'); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'categories.%' +WHERE r.slug IN ('owner', 'admin'); + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('categories.read', 'categories.create', 'categories.update') +WHERE r.slug = 'editor'; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'categories.read' +WHERE r.slug = 'viewer'; diff --git a/database/migrations/004_user_types_and_business_members.sql b/database/migrations/004_user_types_and_business_members.sql new file mode 100644 index 0000000..d6c7ba1 --- /dev/null +++ b/database/migrations/004_user_types_and_business_members.sql @@ -0,0 +1,128 @@ +-- Meshkee CMS — three user types: super_admin, business_owner, customer +-- Businesses are created by super admin (no longer tied 1:1 to user on register) + +-- --------------------------------------------------------------------------- +-- business_users (owners/staff assigned to a business by super admin) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS business_users ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + is_owner BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT business_users_business_user_unique UNIQUE (business_id, user_id), + CONSTRAINT business_users_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT business_users_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_business_users_user_id ON business_users (user_id); +CREATE INDEX IF NOT EXISTS idx_business_users_business_id ON business_users (business_id); + +-- Migrate legacy businesses.user_id links (only when upgrading old databases) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'businesses' + AND column_name = 'user_id' + ) THEN + INSERT INTO business_users (business_id, user_id, is_owner) + SELECT id, user_id, TRUE + FROM businesses + WHERE user_id IS NOT NULL + ON CONFLICT (business_id, user_id) DO NOTHING; + + ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_user_id_fkey; + ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_user_id_unique; + ALTER TABLE businesses DROP COLUMN user_id; + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- business_customers (users who registered on a business website) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS business_customers ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT business_customers_business_user_unique UNIQUE (business_id, user_id), + CONSTRAINT business_customers_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT business_customers_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_business_customers_user_id ON business_customers (user_id); +CREATE INDEX IF NOT EXISTS idx_business_customers_business_id ON business_customers (business_id); + +-- --------------------------------------------------------------------------- +-- roles: super_admin, business_owner, customer +-- --------------------------------------------------------------------------- +INSERT INTO roles (name, slug, description, is_system) VALUES + ('Super Admin', 'super_admin', 'Full platform control — manages businesses, domains, and owners', TRUE), + ('Business Owner', 'business_owner', 'Manages assigned business dashboard', TRUE), + ('Customer', 'customer', 'Registered user on a business website', TRUE) +ON CONFLICT (slug) DO NOTHING; + +-- Migrate legacy owner role assignments to business_owner +UPDATE user_roles ur +SET role_id = (SELECT id FROM roles WHERE slug = 'business_owner') +WHERE role_id = (SELECT id FROM roles WHERE slug = 'owner'); + +-- --------------------------------------------------------------------------- +-- permissions: platform-level (super admin) +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View all businesses', 'businesses.read', 'businesses', 'View all businesses'), + ('Create businesses', 'businesses.create', 'businesses', 'Create new businesses'), + ('Update businesses', 'businesses.update', 'businesses', 'Edit businesses'), + ('Delete businesses', 'businesses.delete', 'businesses', 'Delete businesses'), + ('Assign business owners', 'businesses.assign', 'businesses', 'Assign owners to businesses'), + ('View all users', 'platform.users.read', 'platform', 'View all platform users'), + ('Manage all users', 'platform.users.manage', 'platform', 'Create and manage platform users') +ON CONFLICT (slug) DO NOTHING; + +-- super_admin: all permissions +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +CROSS JOIN permissions p +WHERE r.slug = 'super_admin' +ON CONFLICT DO NOTHING; + +-- business_owner: same as legacy owner (business + content + media + categories + domains read) +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ( + 'business.read', 'business.update', + 'domains.read', 'domains.manage', + 'products.read', 'products.create', 'products.update', 'products.delete', 'products.publish', + 'blogs.read', 'blogs.create', 'blogs.update', 'blogs.delete', 'blogs.publish', + 'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.delete', 'portfolios.publish', + 'media.read', 'media.create', 'media.update', 'media.delete', + 'categories.read', 'categories.create', 'categories.update', 'categories.delete', + 'users.read' +) +WHERE r.slug = 'business_owner' +ON CONFLICT DO NOTHING; + +-- customer: no CMS permissions for now (orders/favorites added later) +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View own orders', 'orders.read', 'orders', 'View own orders'), + ('View own favorites', 'favorites.read', 'favorites', 'View own favorites') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('orders.read', 'favorites.read') +WHERE r.slug = 'customer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/005_business_team_roles.sql b/database/migrations/005_business_team_roles.sql new file mode 100644 index 0000000..da4f3b9 --- /dev/null +++ b/database/migrations/005_business_team_roles.sql @@ -0,0 +1,79 @@ +-- Business team: owners can add staff with limited per-business roles + +-- --------------------------------------------------------------------------- +-- business_users: add role_id for staff permissions (owners use is_owner=true) +-- --------------------------------------------------------------------------- +ALTER TABLE business_users + ADD COLUMN IF NOT EXISTS role_id BIGINT, + ADD COLUMN IF NOT EXISTS invited_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_role_id_fkey; +ALTER TABLE business_users + ADD CONSTRAINT business_users_role_id_fkey + FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE RESTRICT; + +ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_invited_by_fkey; +ALTER TABLE business_users + ADD CONSTRAINT business_users_invited_by_fkey + FOREIGN KEY (invited_by) REFERENCES users (id) ON DELETE SET NULL; + +ALTER TABLE business_users DROP CONSTRAINT IF EXISTS business_users_member_role_check; +ALTER TABLE business_users + ADD CONSTRAINT business_users_member_role_check CHECK ( + (is_owner = TRUE AND role_id IS NULL) + OR (is_owner = FALSE AND role_id IS NOT NULL) + ); + +CREATE INDEX IF NOT EXISTS idx_business_users_role_id ON business_users (role_id); + +DROP TRIGGER IF EXISTS business_users_set_updated_at ON business_users; +CREATE TRIGGER business_users_set_updated_at + BEFORE UPDATE ON business_users + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- business_staff global role (dashboard access for invited team members) +-- --------------------------------------------------------------------------- +INSERT INTO roles (name, slug, description, is_system) VALUES + ('Business Staff', 'business_staff', 'Team member on a business with limited permissions', TRUE) +ON CONFLICT (slug) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- team management permissions (for business owners) +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View business team', 'business.team.read', 'business_team', 'View team members of a business'), + ('Invite business team', 'business.team.invite', 'business_team', 'Add team members to a business'), + ('Update business team', 'business.team.update', 'business_team', 'Change team member roles'), + ('Remove business team', 'business.team.remove', 'business_team', 'Remove team members from a business') +ON CONFLICT (slug) DO NOTHING; + +-- business_owner gets team management permissions +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'business.team.%' +WHERE r.slug = 'business_owner' +ON CONFLICT DO NOTHING; + +-- admin staff role: almost full business access + team read (not invite/remove owners) +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ( + 'business.read', 'business.update', + 'domains.read', + 'products.read', 'products.create', 'products.update', 'products.delete', 'products.publish', + 'blogs.read', 'blogs.create', 'blogs.update', 'blogs.delete', 'blogs.publish', + 'portfolios.read', 'portfolios.create', 'portfolios.update', 'portfolios.delete', 'portfolios.publish', + 'media.read', 'media.create', 'media.update', 'media.delete', + 'categories.read', 'categories.create', 'categories.update', 'categories.delete', + 'business.team.read' +) +WHERE r.slug = 'admin' +ON CONFLICT DO NOTHING; + +-- editor & viewer already seeded in 002 — ensure business_staff has no extra perms +-- business_staff global role: no permissions (permissions come from business_users.role_id) diff --git a/database/migrations/006_business_categories.sql b/database/migrations/006_business_categories.sql new file mode 100644 index 0000000..7289fe7 --- /dev/null +++ b/database/migrations/006_business_categories.sql @@ -0,0 +1,75 @@ +-- System-wide business categories (not scoped to any business) +-- Businesses are tagged with one or more of these categories + +-- --------------------------------------------------------------------------- +-- business_categories (platform / system level) +-- --------------------------------------------------------------------------- +CREATE TABLE business_categories ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + parent_id BIGINT, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + icon VARCHAR(100), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT business_categories_slug_unique UNIQUE (slug), + CONSTRAINT business_categories_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES business_categories (id) ON DELETE SET NULL, + CONSTRAINT business_categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$') +); + +CREATE INDEX idx_business_categories_parent_id ON business_categories (parent_id); +CREATE INDEX idx_business_categories_sort_order ON business_categories (sort_order); +CREATE INDEX idx_business_categories_active + ON business_categories (is_active) WHERE is_active = TRUE; + +CREATE TRIGGER business_categories_set_updated_at + BEFORE UPDATE ON business_categories + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- business_category_assignments (business ↔ system category, many-to-many) +-- --------------------------------------------------------------------------- +CREATE TABLE business_category_assignments ( + business_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (business_id, category_id), + CONSTRAINT business_category_assignments_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT business_category_assignments_category_id_fkey + FOREIGN KEY (category_id) REFERENCES business_categories (id) ON DELETE CASCADE +); + +CREATE INDEX idx_business_category_assignments_category_id + ON business_category_assignments (category_id); + +-- --------------------------------------------------------------------------- +-- permissions (super admin manages business categories) +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View business categories', 'business_categories.read', 'business_categories', 'View system business categories'), + ('Create business categories', 'business_categories.create', 'business_categories', 'Create system business categories'), + ('Update business categories', 'business_categories.update', 'business_categories', 'Edit system business categories'), + ('Delete business categories', 'business_categories.delete', 'business_categories', 'Delete system business categories') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'business_categories.%' +WHERE r.slug = 'super_admin' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'business_categories.read' +WHERE r.slug IN ('business_owner', 'admin') +ON CONFLICT DO NOTHING; diff --git a/database/migrations/006_user_profile.sql b/database/migrations/006_user_profile.sql new file mode 100644 index 0000000..0ea2580 --- /dev/null +++ b/database/migrations/006_user_profile.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS profile JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/database/migrations/007_business_i18n_fields.sql b/database/migrations/007_business_i18n_fields.sql new file mode 100644 index 0000000..5d1a055 --- /dev/null +++ b/database/migrations/007_business_i18n_fields.sql @@ -0,0 +1,9 @@ +-- Business i18n fields: name_fa, about (English name uses existing `name` column) + +ALTER TABLE businesses + ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255), + ADD COLUMN IF NOT EXISTS about TEXT; + +UPDATE businesses +SET name_fa = COALESCE(name_fa, name) +WHERE name_fa IS NULL; diff --git a/database/migrations/007_domain_expiry_active.sql b/database/migrations/007_domain_expiry_active.sql new file mode 100644 index 0000000..190cabd --- /dev/null +++ b/database/migrations/007_domain_expiry_active.sql @@ -0,0 +1,9 @@ +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE INDEX IF NOT EXISTS idx_domains_is_active ON domains (is_active); + +UPDATE domains +SET expires_at = NOW() + INTERVAL '365 days' +WHERE expires_at IS NULL; diff --git a/database/migrations/008_remove_name_en.sql b/database/migrations/008_remove_name_en.sql new file mode 100644 index 0000000..07f419b --- /dev/null +++ b/database/migrations/008_remove_name_en.sql @@ -0,0 +1,7 @@ +-- Remove redundant name_en (use name for English/default name) + +UPDATE businesses +SET name = COALESCE(name, name_en) +WHERE name IS NULL AND name_en IS NOT NULL; + +ALTER TABLE businesses DROP COLUMN IF EXISTS name_en; diff --git a/database/migrations/009_categories_name_fa.sql b/database/migrations/009_categories_name_fa.sql new file mode 100644 index 0000000..41ea746 --- /dev/null +++ b/database/migrations/009_categories_name_fa.sql @@ -0,0 +1,4 @@ +-- Persian display name for product/blog/portfolio categories + +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255); diff --git a/database/migrations/010_category_variations.sql b/database/migrations/010_category_variations.sql new file mode 100644 index 0000000..4a80cfd --- /dev/null +++ b/database/migrations/010_category_variations.sql @@ -0,0 +1,104 @@ +-- Category variations & options (product categories only) +-- Variation types: color (predefined palette), size (user-defined), custom (user-defined name + values) + +CREATE TYPE variation_type AS ENUM ('color', 'size', 'custom'); + +CREATE TABLE category_variations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + variation_type variation_type NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_variations_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT category_variations_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX category_variations_one_color_per_category + ON category_variations (category_id) WHERE variation_type = 'color'; + +CREATE UNIQUE INDEX category_variations_one_size_per_category + ON category_variations (category_id) WHERE variation_type = 'size'; + +CREATE UNIQUE INDEX category_variations_custom_name_per_category + ON category_variations (category_id, name) WHERE variation_type = 'custom'; + +CREATE INDEX idx_category_variations_category_id ON category_variations (category_id); +CREATE INDEX idx_category_variations_business_id ON category_variations (business_id); + +CREATE TRIGGER category_variations_set_updated_at + BEFORE UPDATE ON category_variations + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE category_variation_options ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + variation_id BIGINT NOT NULL, + label VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + color_hex VARCHAR(7), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_variation_options_variation_id_fkey + FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE CASCADE, + CONSTRAINT category_variation_options_unique_value + UNIQUE (variation_id, value) +); + +CREATE INDEX idx_category_variation_options_variation_id + ON category_variation_options (variation_id); + +-- Product variants (combinations of category variation options) +CREATE TABLE product_variants ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + sku VARCHAR(100), + price NUMERIC(12, 2), + compare_at_price NUMERIC(12, 2), + stock_quantity INTEGER, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT product_variants_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT product_variants_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT product_variants_stock_non_negative + CHECK (stock_quantity IS NULL OR stock_quantity >= 0) +); + +CREATE INDEX idx_product_variants_product_id ON product_variants (product_id); +CREATE INDEX idx_product_variants_business_id ON product_variants (business_id); + +CREATE TRIGGER product_variants_set_updated_at + BEFORE UPDATE ON product_variants + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE product_variant_selections ( + variant_id BIGINT NOT NULL, + variation_id BIGINT NOT NULL, + option_id BIGINT NOT NULL, + + CONSTRAINT product_variant_selections_pkey PRIMARY KEY (variant_id, variation_id), + CONSTRAINT product_variant_selections_variant_id_fkey + FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE CASCADE, + CONSTRAINT product_variant_selections_variation_id_fkey + FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT, + CONSTRAINT product_variant_selections_option_id_fkey + FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT, + CONSTRAINT product_variant_selections_unique_option + UNIQUE (variant_id, option_id) +); + +CREATE INDEX idx_product_variant_selections_option_id + ON product_variant_selections (option_id); diff --git a/database/migrations/011_category_technical_forms.sql b/database/migrations/011_category_technical_forms.sql new file mode 100644 index 0000000..540fa91 --- /dev/null +++ b/database/migrations/011_category_technical_forms.sql @@ -0,0 +1,118 @@ +-- Category technical forms: dynamic form definitions per product category +-- Field types: text, textarea, select, multi_select + +CREATE TYPE technical_field_type AS ENUM ('text', 'textarea', 'select', 'multi_select'); + +CREATE TABLE category_technical_forms ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_technical_forms_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT category_technical_forms_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE, + CONSTRAINT category_technical_forms_category_unique + UNIQUE (category_id) +); + +CREATE INDEX idx_category_technical_forms_business_id + ON category_technical_forms (business_id); + +CREATE TRIGGER category_technical_forms_set_updated_at + BEFORE UPDATE ON category_technical_forms + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE category_technical_form_fields ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + form_id BIGINT NOT NULL, + label VARCHAR(255) NOT NULL, + field_key VARCHAR(255) NOT NULL, + field_type technical_field_type NOT NULL, + is_required BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_technical_form_fields_form_id_fkey + FOREIGN KEY (form_id) REFERENCES category_technical_forms (id) ON DELETE CASCADE, + CONSTRAINT category_technical_form_fields_unique_key + UNIQUE (form_id, field_key) +); + +CREATE INDEX idx_category_technical_form_fields_form_id + ON category_technical_form_fields (form_id); + +CREATE TRIGGER category_technical_form_fields_set_updated_at + BEFORE UPDATE ON category_technical_form_fields + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE category_technical_form_field_options ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + field_id BIGINT NOT NULL, + label VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT category_technical_form_field_options_field_id_fkey + FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE, + CONSTRAINT category_technical_form_field_options_unique_value + UNIQUE (field_id, value) +); + +CREATE INDEX idx_category_technical_form_field_options_field_id + ON category_technical_form_field_options (field_id); + +-- Product technical data values (one row per product per field) +CREATE TABLE product_technical_field_values ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + field_id BIGINT NOT NULL, + text_value TEXT, + option_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT product_technical_field_values_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT product_technical_field_values_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT product_technical_field_values_field_id_fkey + FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE, + CONSTRAINT product_technical_field_values_option_id_fkey + FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE SET NULL, + CONSTRAINT product_technical_field_values_unique + UNIQUE (product_id, field_id) +); + +CREATE INDEX idx_product_technical_field_values_product_id + ON product_technical_field_values (product_id); +CREATE INDEX idx_product_technical_field_values_business_id + ON product_technical_field_values (business_id); + +CREATE TRIGGER product_technical_field_values_set_updated_at + BEFORE UPDATE ON product_technical_field_values + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- Multi-select option selections +CREATE TABLE product_technical_field_value_options ( + field_value_id BIGINT NOT NULL, + option_id BIGINT NOT NULL, + + CONSTRAINT product_technical_field_value_options_pkey + PRIMARY KEY (field_value_id, option_id), + CONSTRAINT product_technical_field_value_options_field_value_id_fkey + FOREIGN KEY (field_value_id) REFERENCES product_technical_field_values (id) ON DELETE CASCADE, + CONSTRAINT product_technical_field_value_options_option_id_fkey + FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE CASCADE +); + +CREATE INDEX idx_product_technical_field_value_options_option_id + ON product_technical_field_value_options (option_id); diff --git a/database/migrations/012_comments.sql b/database/migrations/012_comments.sql new file mode 100644 index 0000000..4de9199 --- /dev/null +++ b/database/migrations/012_comments.sql @@ -0,0 +1,83 @@ +-- Meshkee CMS — polymorphic comments (product, blog, portfolio) + +CREATE TABLE comments ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + entity_type media_entity_type NOT NULL, + entity_id BIGINT NOT NULL, + author_name VARCHAR(255) NOT NULL, + author_email VARCHAR(255), + text TEXT NOT NULL, + is_approved BOOLEAN NOT NULL DEFAULT FALSE, + approved_at TIMESTAMPTZ, + approved_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT comments_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT comments_approved_by_fkey + FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT comments_text_nonempty CHECK (char_length(trim(text)) > 0), + CONSTRAINT comments_author_name_nonempty CHECK (char_length(trim(author_name)) > 0) +); + +CREATE INDEX idx_comments_business_approval + ON comments (business_id, is_approved, created_at DESC); + +CREATE INDEX idx_comments_entity + ON comments (business_id, entity_type, entity_id, is_approved, created_at DESC); + +CREATE TRIGGER comments_set_updated_at + BEFORE UPDATE ON comments + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View comments', 'comments.read', 'comments', 'View comments on business content'), + ('Approve comments', 'comments.approve', 'comments', 'Approve or reject comments'), + ('Delete comments', 'comments.delete', 'comments', 'Delete comments') +ON CONFLICT (slug) DO NOTHING; + +-- business_owner +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'comments.%' +WHERE r.slug = 'business_owner' +ON CONFLICT DO NOTHING; + +-- owner (legacy global role) +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'comments.%' +WHERE r.slug = 'owner' +ON CONFLICT DO NOTHING; + +-- admin +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('comments.read', 'comments.approve', 'comments.delete') +WHERE r.slug = 'admin' +ON CONFLICT DO NOTHING; + +-- editor: read + approve (moderate), no delete +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('comments.read', 'comments.approve') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +-- viewer: read only +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'comments.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/013_expert_reviews.sql b/database/migrations/013_expert_reviews.sql new file mode 100644 index 0000000..b61f0f3 --- /dev/null +++ b/database/migrations/013_expert_reviews.sql @@ -0,0 +1,83 @@ +-- Meshkee CMS — expert product reviews + +CREATE TABLE expert_reviews ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + author_name VARCHAR(255) NOT NULL, + author_email VARCHAR(255), + rate SMALLINT NOT NULL, + positive_points TEXT[] NOT NULL DEFAULT '{}', + negative_points TEXT[] NOT NULL DEFAULT '{}', + text TEXT NOT NULL, + is_approved BOOLEAN NOT NULL DEFAULT FALSE, + approved_at TIMESTAMPTZ, + approved_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT expert_reviews_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT expert_reviews_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT expert_reviews_approved_by_fkey + FOREIGN KEY (approved_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT expert_reviews_rate_range CHECK (rate >= 1 AND rate <= 10), + CONSTRAINT expert_reviews_text_nonempty CHECK (char_length(trim(text)) > 0), + CONSTRAINT expert_reviews_author_name_nonempty CHECK (char_length(trim(author_name)) > 0) +); + +CREATE INDEX idx_expert_reviews_business_approval + ON expert_reviews (business_id, is_approved, created_at DESC); + +CREATE INDEX idx_expert_reviews_product + ON expert_reviews (business_id, product_id, is_approved, created_at DESC); + +CREATE TRIGGER expert_reviews_set_updated_at + BEFORE UPDATE ON expert_reviews + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View expert reviews', 'expert_reviews.read', 'expert_reviews', 'View expert product reviews'), + ('Approve expert reviews', 'expert_reviews.approve', 'expert_reviews', 'Approve or reject expert reviews'), + ('Delete expert reviews', 'expert_reviews.delete', 'expert_reviews', 'Delete expert reviews') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'expert_reviews.%' +WHERE r.slug = 'business_owner' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'expert_reviews.%' +WHERE r.slug = 'owner' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('expert_reviews.read', 'expert_reviews.approve', 'expert_reviews.delete') +WHERE r.slug = 'admin' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('expert_reviews.read', 'expert_reviews.approve') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'expert_reviews.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/014_addresses.sql b/database/migrations/014_addresses.sql new file mode 100644 index 0000000..8b4bd7d --- /dev/null +++ b/database/migrations/014_addresses.sql @@ -0,0 +1,38 @@ +-- Meshkee CMS — addresses owned by exactly one user or business + +CREATE TABLE addresses ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT, + business_id BIGINT, + province VARCHAR(100) NOT NULL, + city VARCHAR(100) NOT NULL, + address TEXT NOT NULL, + postal_code VARCHAR(20) NOT NULL, + landline VARCHAR(30), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT addresses_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT addresses_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT addresses_exactly_one_owner CHECK ( + (user_id IS NOT NULL AND business_id IS NULL) + OR (user_id IS NULL AND business_id IS NOT NULL) + ), + CONSTRAINT addresses_province_nonempty CHECK (char_length(trim(province)) > 0), + CONSTRAINT addresses_city_nonempty CHECK (char_length(trim(city)) > 0), + CONSTRAINT addresses_address_nonempty CHECK (char_length(trim(address)) > 0), + CONSTRAINT addresses_postal_code_nonempty CHECK (char_length(trim(postal_code)) > 0), + CONSTRAINT addresses_landline_nonempty_optional CHECK ( + landline IS NULL OR char_length(trim(landline)) > 0 + ) +); + +CREATE INDEX idx_addresses_user_id ON addresses (user_id) WHERE user_id IS NOT NULL; +CREATE INDEX idx_addresses_business_id ON addresses (business_id) WHERE business_id IS NOT NULL; + +CREATE TRIGGER addresses_set_updated_at + BEFORE UPDATE ON addresses + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/database/migrations/015_business_profile.sql b/database/migrations/015_business_profile.sql new file mode 100644 index 0000000..cf19a89 --- /dev/null +++ b/database/migrations/015_business_profile.sql @@ -0,0 +1,12 @@ +-- Business profile fields for dashboard / public storefront + +ALTER TABLE businesses + ADD COLUMN IF NOT EXISTS vision TEXT, + ADD COLUMN IF NOT EXISTS emails JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS phone_numbers JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS social_media JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS logo_media_id BIGINT; + +ALTER TABLE businesses + ADD CONSTRAINT businesses_logo_media_id_fkey + FOREIGN KEY (logo_media_id) REFERENCES media (id) ON DELETE SET NULL; diff --git a/database/migrations/015_cities.sql b/database/migrations/015_cities.sql new file mode 100644 index 0000000..bc23824 --- /dev/null +++ b/database/migrations/015_cities.sql @@ -0,0 +1,73 @@ +-- Meshkee CMS — location reference tree (country → province → city) + +CREATE TYPE city_level AS ENUM ('country', 'province', 'city'); + +CREATE TABLE cities ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + parent_id BIGINT, + level city_level NOT NULL, + name_fa VARCHAR(255) NOT NULL, + name_en VARCHAR(255) NOT NULL, + landline_code VARCHAR(10), + slug VARCHAR(100) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT cities_slug_unique UNIQUE (slug), + CONSTRAINT cities_parent_id_fkey + FOREIGN KEY (parent_id) REFERENCES cities (id) ON DELETE CASCADE, + CONSTRAINT cities_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + CONSTRAINT cities_name_fa_nonempty CHECK (char_length(trim(name_fa)) > 0), + CONSTRAINT cities_name_en_nonempty CHECK (char_length(trim(name_en)) > 0), + CONSTRAINT cities_landline_code_nonempty_optional CHECK ( + landline_code IS NULL OR char_length(trim(landline_code)) > 0 + ), + CONSTRAINT cities_country_root CHECK ( + (level = 'country' AND parent_id IS NULL) + OR (level <> 'country' AND parent_id IS NOT NULL) + ) +); + +CREATE INDEX idx_cities_parent_id ON cities (parent_id); +CREATE INDEX idx_cities_level ON cities (level); +CREATE INDEX idx_cities_level_parent ON cities (level, parent_id, sort_order); +CREATE INDEX idx_cities_active ON cities (is_active) WHERE is_active = TRUE; + +CREATE TRIGGER cities_set_updated_at + BEFORE UPDATE ON cities + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE OR REPLACE FUNCTION cities_validate_parent_level() +RETURNS TRIGGER AS $$ +DECLARE + parent_level city_level; +BEGIN + IF NEW.level = 'country' THEN + RETURN NEW; + END IF; + + SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'parent city not found'; + END IF; + + IF NEW.level = 'province' AND parent_level <> 'country' THEN + RAISE EXCEPTION 'province parent must be a country'; + END IF; + + IF NEW.level = 'city' AND parent_level <> 'province' THEN + RAISE EXCEPTION 'city parent must be a province'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER cities_validate_parent_level + BEFORE INSERT OR UPDATE ON cities + FOR EACH ROW + EXECUTE FUNCTION cities_validate_parent_level(); diff --git a/database/migrations/016_product_variation_values.sql b/database/migrations/016_product_variation_values.sql new file mode 100644 index 0000000..70d6721 --- /dev/null +++ b/database/migrations/016_product_variation_values.sql @@ -0,0 +1,20 @@ +-- Product-level variation value selections (which category options apply to a product). +-- Store item variants are created later from these values. + +CREATE TABLE product_variation_values ( + product_id BIGINT NOT NULL, + variation_id BIGINT NOT NULL, + option_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT product_variation_values_pkey PRIMARY KEY (product_id, option_id), + CONSTRAINT product_variation_values_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT product_variation_values_variation_id_fkey + FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT, + CONSTRAINT product_variation_values_option_id_fkey + FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT +); + +CREATE INDEX idx_product_variation_values_variation_id + ON product_variation_values (variation_id); diff --git a/database/migrations/017_product_variant_festival.sql b/database/migrations/017_product_variant_festival.sql new file mode 100644 index 0000000..a17a43c --- /dev/null +++ b/database/migrations/017_product_variant_festival.sql @@ -0,0 +1,3 @@ +-- Festival flag for store item variants +ALTER TABLE product_variants + ADD COLUMN is_festival BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/database/migrations/018_product_variant_reward_points.sql b/database/migrations/018_product_variant_reward_points.sql new file mode 100644 index 0000000..e1fdf43 --- /dev/null +++ b/database/migrations/018_product_variant_reward_points.sql @@ -0,0 +1,7 @@ +-- Reward points earned when purchasing a store item variant (festival rewards — usage later) +ALTER TABLE product_variants + ADD COLUMN reward_points INTEGER; + +ALTER TABLE product_variants + ADD CONSTRAINT product_variants_reward_points_non_negative + CHECK (reward_points IS NULL OR reward_points >= 0); diff --git a/database/migrations/019_cart_and_orders.sql b/database/migrations/019_cart_and_orders.sql new file mode 100644 index 0000000..de5b538 --- /dev/null +++ b/database/migrations/019_cart_and_orders.sql @@ -0,0 +1,205 @@ +-- Meshkee CMS — shopping cart and orders + +-- --------------------------------------------------------------------------- +-- enums +-- --------------------------------------------------------------------------- +DO $$ BEGIN + CREATE TYPE order_status AS ENUM ( + 'pending', + 'confirmed', + 'processing', + 'shipped', + 'delivered', + 'cancelled' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE order_source AS ENUM ('website', 'admin'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- --------------------------------------------------------------------------- +-- carts (one per customer per business) +-- --------------------------------------------------------------------------- +CREATE TABLE carts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT carts_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT carts_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT carts_business_user_unique UNIQUE (business_id, user_id) +); + +CREATE INDEX idx_carts_business_id ON carts (business_id); +CREATE INDEX idx_carts_user_id ON carts (user_id); + +CREATE TRIGGER carts_set_updated_at + BEFORE UPDATE ON carts + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- cart items (product variants in cart) +-- --------------------------------------------------------------------------- +CREATE TABLE cart_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + cart_id BIGINT NOT NULL, + variant_id BIGINT NOT NULL, + quantity INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT cart_items_cart_id_fkey + FOREIGN KEY (cart_id) REFERENCES carts (id) ON DELETE CASCADE, + CONSTRAINT cart_items_variant_id_fkey + FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE CASCADE, + CONSTRAINT cart_items_cart_variant_unique UNIQUE (cart_id, variant_id), + CONSTRAINT cart_items_quantity_positive CHECK (quantity > 0) +); + +CREATE INDEX idx_cart_items_cart_id ON cart_items (cart_id); +CREATE INDEX idx_cart_items_variant_id ON cart_items (variant_id); + +CREATE TRIGGER cart_items_set_updated_at + BEFORE UPDATE ON cart_items + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- orders +-- --------------------------------------------------------------------------- +CREATE TABLE orders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + order_number VARCHAR(30) NOT NULL, + status order_status NOT NULL DEFAULT 'pending', + source order_source NOT NULL DEFAULT 'website', + subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0, + shipping_total NUMERIC(12, 2) NOT NULL DEFAULT 0, + discount_total NUMERIC(12, 2) NOT NULL DEFAULT 0, + total NUMERIC(12, 2) NOT NULL DEFAULT 0, + shipping_address JSONB NOT NULL DEFAULT '{}', + address_id BIGINT, + customer_notes TEXT, + admin_notes TEXT, + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT orders_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT orders_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT orders_address_id_fkey + FOREIGN KEY (address_id) REFERENCES addresses (id) ON DELETE SET NULL, + CONSTRAINT orders_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT orders_business_order_number_unique UNIQUE (business_id, order_number), + CONSTRAINT orders_subtotal_non_negative CHECK (subtotal >= 0), + CONSTRAINT orders_shipping_total_non_negative CHECK (shipping_total >= 0), + CONSTRAINT orders_discount_total_non_negative CHECK (discount_total >= 0), + CONSTRAINT orders_total_non_negative CHECK (total >= 0) +); + +CREATE INDEX idx_orders_business_created + ON orders (business_id, created_at DESC); + +CREATE INDEX idx_orders_business_user + ON orders (business_id, user_id, created_at DESC); + +CREATE INDEX idx_orders_business_status + ON orders (business_id, status, created_at DESC); + +CREATE TRIGGER orders_set_updated_at + BEFORE UPDATE ON orders + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- order items (line items with price snapshots) +-- --------------------------------------------------------------------------- +CREATE TABLE order_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + order_id BIGINT NOT NULL, + variant_id BIGINT, + product_id BIGINT NOT NULL, + product_title VARCHAR(255) NOT NULL, + variant_sku VARCHAR(100), + unit_price NUMERIC(12, 2) NOT NULL, + compare_at_price NUMERIC(12, 2), + quantity INT NOT NULL, + line_total NUMERIC(12, 2) NOT NULL, + selections_snapshot JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT order_items_order_id_fkey + FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE, + CONSTRAINT order_items_variant_id_fkey + FOREIGN KEY (variant_id) REFERENCES product_variants (id) ON DELETE SET NULL, + CONSTRAINT order_items_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT, + CONSTRAINT order_items_quantity_positive CHECK (quantity > 0), + CONSTRAINT order_items_unit_price_non_negative CHECK (unit_price >= 0), + CONSTRAINT order_items_line_total_non_negative CHECK (line_total >= 0) +); + +CREATE INDEX idx_order_items_order_id ON order_items (order_id); +CREATE INDEX idx_order_items_variant_id ON order_items (variant_id); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('Create orders', 'orders.create', 'orders', 'Create orders on behalf of customers'), + ('Update orders', 'orders.update', 'orders', 'Update order status and admin notes') +ON CONFLICT (slug) DO NOTHING; + +-- business_owner +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update') +WHERE r.slug = 'business_owner' +ON CONFLICT DO NOTHING; + +-- owner (legacy global role) +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update') +WHERE r.slug = 'owner' +ON CONFLICT DO NOTHING; + +-- admin +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('orders.read', 'orders.create', 'orders.update') +WHERE r.slug = 'admin' +ON CONFLICT DO NOTHING; + +-- editor: read + update status +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('orders.read', 'orders.update') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +-- viewer: read only +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'orders.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/020_store_items_and_variants.sql b/database/migrations/020_store_items_and_variants.sql new file mode 100644 index 0000000..05728ae --- /dev/null +++ b/database/migrations/020_store_items_and_variants.sql @@ -0,0 +1,198 @@ +-- Meshkee CMS — store items (one per product) and store item variants (purchasable SKUs) +-- Replaces product_variants / product_variant_selections + +-- --------------------------------------------------------------------------- +-- store_items (one listing per product in the shop) +-- --------------------------------------------------------------------------- +CREATE TABLE store_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT store_items_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT store_items_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT store_items_business_product_unique UNIQUE (business_id, product_id) +); + +CREATE INDEX idx_store_items_business_id ON store_items (business_id); +CREATE INDEX idx_store_items_product_id ON store_items (product_id); + +CREATE TRIGGER store_items_set_updated_at + BEFORE UPDATE ON store_items + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- store_item_variants (purchasable combinations with price & stock) +-- --------------------------------------------------------------------------- +CREATE TABLE store_item_variants ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + store_item_id BIGINT NOT NULL, + business_id BIGINT NOT NULL, + sku VARCHAR(100), + price NUMERIC(12, 2), + compare_at_price NUMERIC(12, 2), + stock_quantity INTEGER, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_festival BOOLEAN NOT NULL DEFAULT FALSE, + reward_points INTEGER, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + legacy_product_variant_id BIGINT, + + CONSTRAINT store_item_variants_store_item_id_fkey + FOREIGN KEY (store_item_id) REFERENCES store_items (id) ON DELETE CASCADE, + CONSTRAINT store_item_variants_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT store_item_variants_stock_non_negative + CHECK (stock_quantity IS NULL OR stock_quantity >= 0), + CONSTRAINT store_item_variants_reward_points_non_negative + CHECK (reward_points IS NULL OR reward_points >= 0) +); + +CREATE INDEX idx_store_item_variants_store_item_id ON store_item_variants (store_item_id); +CREATE INDEX idx_store_item_variants_business_id ON store_item_variants (business_id); +CREATE UNIQUE INDEX idx_store_item_variants_legacy_id + ON store_item_variants (legacy_product_variant_id) + WHERE legacy_product_variant_id IS NOT NULL; + +CREATE TRIGGER store_item_variants_set_updated_at + BEFORE UPDATE ON store_item_variants + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- store_item_variant_selections +-- --------------------------------------------------------------------------- +CREATE TABLE store_item_variant_selections ( + variant_id BIGINT NOT NULL, + variation_id BIGINT NOT NULL, + option_id BIGINT NOT NULL, + + CONSTRAINT store_item_variant_selections_pkey PRIMARY KEY (variant_id, variation_id), + CONSTRAINT store_item_variant_selections_variant_id_fkey + FOREIGN KEY (variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE, + CONSTRAINT store_item_variant_selections_variation_id_fkey + FOREIGN KEY (variation_id) REFERENCES category_variations (id) ON DELETE RESTRICT, + CONSTRAINT store_item_variant_selections_option_id_fkey + FOREIGN KEY (option_id) REFERENCES category_variation_options (id) ON DELETE RESTRICT, + CONSTRAINT store_item_variant_selections_unique_option + UNIQUE (variant_id, option_id) +); + +CREATE INDEX idx_store_item_variant_selections_option_id + ON store_item_variant_selections (option_id); + +-- --------------------------------------------------------------------------- +-- migrate product_variants → store_items + store_item_variants +-- --------------------------------------------------------------------------- +INSERT INTO store_items (business_id, product_id, is_active, sort_order, created_at, updated_at) +SELECT DISTINCT + pv.business_id, + pv.product_id, + TRUE, + 0, + NOW(), + NOW() +FROM product_variants pv; + +INSERT INTO store_item_variants ( + store_item_id, + business_id, + sku, + price, + compare_at_price, + stock_quantity, + is_active, + is_festival, + reward_points, + sort_order, + created_at, + updated_at, + legacy_product_variant_id +) +SELECT + si.id, + pv.business_id, + pv.sku, + pv.price, + pv.compare_at_price, + pv.stock_quantity, + pv.is_active, + pv.is_festival, + pv.reward_points, + pv.sort_order, + pv.created_at, + pv.updated_at, + pv.id +FROM product_variants pv +JOIN store_items si + ON si.business_id = pv.business_id + AND si.product_id = pv.product_id; + +INSERT INTO store_item_variant_selections (variant_id, variation_id, option_id) +SELECT + siv.id, + pvs.variation_id, + pvs.option_id +FROM product_variant_selections pvs +JOIN store_item_variants siv + ON siv.legacy_product_variant_id = pvs.variant_id; + +-- --------------------------------------------------------------------------- +-- repoint cart_items and order_items to store_item_variants +-- --------------------------------------------------------------------------- +ALTER TABLE cart_items DROP CONSTRAINT cart_items_variant_id_fkey; +ALTER TABLE cart_items RENAME COLUMN variant_id TO store_item_variant_id; + +UPDATE cart_items ci +SET store_item_variant_id = siv.id +FROM store_item_variants siv +WHERE siv.legacy_product_variant_id = ci.store_item_variant_id; + +ALTER TABLE cart_items + ADD CONSTRAINT cart_items_store_item_variant_id_fkey + FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE CASCADE; + +ALTER TABLE cart_items + DROP CONSTRAINT IF EXISTS cart_items_cart_variant_unique; + +ALTER TABLE cart_items + ADD CONSTRAINT cart_items_cart_store_item_variant_unique + UNIQUE (cart_id, store_item_variant_id); + +DROP INDEX IF EXISTS idx_cart_items_variant_id; +CREATE INDEX idx_cart_items_store_item_variant_id + ON cart_items (store_item_variant_id); + +ALTER TABLE order_items DROP CONSTRAINT order_items_variant_id_fkey; +ALTER TABLE order_items RENAME COLUMN variant_id TO store_item_variant_id; + +UPDATE order_items oi +SET store_item_variant_id = siv.id +FROM store_item_variants siv +WHERE siv.legacy_product_variant_id = oi.store_item_variant_id; + +ALTER TABLE order_items + ADD CONSTRAINT order_items_store_item_variant_id_fkey + FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE SET NULL; + +DROP INDEX IF EXISTS idx_order_items_variant_id; +CREATE INDEX idx_order_items_store_item_variant_id + ON order_items (store_item_variant_id); + +-- --------------------------------------------------------------------------- +-- drop legacy tables +-- --------------------------------------------------------------------------- +DROP TABLE product_variant_selections; +DROP TABLE product_variants; + +ALTER TABLE store_item_variants DROP COLUMN legacy_product_variant_id; +DROP INDEX IF EXISTS idx_store_item_variants_legacy_id; diff --git a/database/migrations/021_business_customer_is_enabled.sql b/database/migrations/021_business_customer_is_enabled.sql new file mode 100644 index 0000000..1843e72 --- /dev/null +++ b/database/migrations/021_business_customer_is_enabled.sql @@ -0,0 +1,6 @@ +-- Business-scoped customer enable/disable (does not deactivate the global user account) +ALTER TABLE business_customers + ADD COLUMN IF NOT EXISTS is_enabled BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE INDEX IF NOT EXISTS idx_business_customers_is_enabled + ON business_customers (business_id, is_enabled); diff --git a/database/migrations/022_transactions.sql b/database/migrations/022_transactions.sql new file mode 100644 index 0000000..263071a --- /dev/null +++ b/database/migrations/022_transactions.sql @@ -0,0 +1,114 @@ +-- Meshkee CMS — payment transactions + +-- --------------------------------------------------------------------------- +-- enums +-- --------------------------------------------------------------------------- +DO $$ BEGIN + CREATE TYPE transaction_type AS ENUM ( + 'pos', + 'cash', + 'transfer', + 'e_payment_gate' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + CREATE TYPE transaction_status AS ENUM ( + 'pending', + 'completed', + 'failed', + 'refunded' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- --------------------------------------------------------------------------- +-- transactions +-- --------------------------------------------------------------------------- +CREATE TABLE transactions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + order_id BIGINT, + user_id BIGINT NOT NULL, + type transaction_type NOT NULL, + status transaction_status NOT NULL DEFAULT 'pending', + amount NUMERIC(12, 2) NOT NULL, + pos_type VARCHAR(100), + gateway_type VARCHAR(100), + transfer_account VARCHAR(255), + transfer_ref_number VARCHAR(100), + notes TEXT, + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT transactions_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT transactions_order_id_fkey + FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE SET NULL, + CONSTRAINT transactions_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT transactions_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT transactions_amount_non_negative CHECK (amount >= 0), + CONSTRAINT transactions_type_fields_check CHECK ( + (type = 'pos' + AND pos_type IS NOT NULL + AND gateway_type IS NULL + AND transfer_account IS NULL + AND transfer_ref_number IS NULL) + OR (type = 'cash' + AND pos_type IS NULL + AND gateway_type IS NULL + AND transfer_account IS NULL + AND transfer_ref_number IS NULL) + OR (type = 'transfer' + AND transfer_account IS NOT NULL + AND transfer_ref_number IS NOT NULL + AND pos_type IS NULL + AND gateway_type IS NULL) + OR (type = 'e_payment_gate' + AND gateway_type IS NOT NULL + AND pos_type IS NULL + AND transfer_account IS NULL + AND transfer_ref_number IS NULL) + ) +); + +CREATE INDEX idx_transactions_business_created + ON transactions (business_id, created_at DESC); + +CREATE INDEX idx_transactions_order_id + ON transactions (order_id); + +CREATE INDEX idx_transactions_business_user + ON transactions (business_id, user_id, created_at DESC); + +CREATE TRIGGER transactions_set_updated_at + BEFORE UPDATE ON transactions + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View transactions', 'transactions.read', 'transactions', 'View payment transactions') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'transactions.read' +WHERE r.slug IN ('business_owner', 'owner', 'admin') +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'transactions.read' +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/023_orders_process_step.sql b/database/migrations/023_orders_process_step.sql new file mode 100644 index 0000000..a90686a --- /dev/null +++ b/database/migrations/023_orders_process_step.sql @@ -0,0 +1,7 @@ +-- Meshkee CMS — order fulfillment process step (from business store settings) + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS process_step_id VARCHAR(64) NOT NULL DEFAULT 'processing'; + +CREATE INDEX IF NOT EXISTS idx_orders_business_process_step + ON orders (business_id, process_step_id); diff --git a/database/migrations/024_shopping_cards.sql b/database/migrations/024_shopping_cards.sql new file mode 100644 index 0000000..033851f --- /dev/null +++ b/database/migrations/024_shopping_cards.sql @@ -0,0 +1,63 @@ +-- Meshkee CMS — saved operator shopping cards (draft orders) + +CREATE TABLE shopping_cards ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + subtotal NUMERIC(12, 2) NOT NULL DEFAULT 0, + total NUMERIC(12, 2) NOT NULL DEFAULT 0, + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT shopping_cards_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT shopping_cards_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT, + CONSTRAINT shopping_cards_created_by_fkey + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL, + CONSTRAINT shopping_cards_subtotal_non_negative CHECK (subtotal >= 0), + CONSTRAINT shopping_cards_total_non_negative CHECK (total >= 0) +); + +CREATE INDEX idx_shopping_cards_business_created + ON shopping_cards (business_id, created_at DESC); + +CREATE INDEX idx_shopping_cards_business_user + ON shopping_cards (business_id, user_id, created_at DESC); + +CREATE TRIGGER shopping_cards_set_updated_at + BEFORE UPDATE ON shopping_cards + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE shopping_card_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + shopping_card_id BIGINT NOT NULL, + store_item_variant_id BIGINT, + product_id BIGINT NOT NULL, + product_title VARCHAR(255) NOT NULL, + variant_sku VARCHAR(100), + unit_price NUMERIC(12, 2) NOT NULL, + compare_at_price NUMERIC(12, 2), + quantity INT NOT NULL, + line_total NUMERIC(12, 2) NOT NULL, + selections_snapshot JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT shopping_card_items_card_id_fkey + FOREIGN KEY (shopping_card_id) REFERENCES shopping_cards (id) ON DELETE CASCADE, + CONSTRAINT shopping_card_items_variant_id_fkey + FOREIGN KEY (store_item_variant_id) REFERENCES store_item_variants (id) ON DELETE SET NULL, + CONSTRAINT shopping_card_items_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE RESTRICT, + CONSTRAINT shopping_card_items_quantity_positive CHECK (quantity > 0), + CONSTRAINT shopping_card_items_unit_price_non_negative CHECK (unit_price >= 0), + CONSTRAINT shopping_card_items_line_total_non_negative CHECK (line_total >= 0) +); + +CREATE INDEX idx_shopping_card_items_card_id + ON shopping_card_items (shopping_card_id); + +CREATE INDEX idx_shopping_card_items_variant_id + ON shopping_card_items (store_item_variant_id); diff --git a/database/migrations/025_blogs_post_type.sql b/database/migrations/025_blogs_post_type.sql new file mode 100644 index 0000000..a5c7ae3 --- /dev/null +++ b/database/migrations/025_blogs_post_type.sql @@ -0,0 +1,9 @@ +-- Meshkee CMS — blog post type (news | article | blog) + +CREATE TYPE blog_post_type AS ENUM ('news', 'article', 'blog'); + +ALTER TABLE blogs + ADD COLUMN post_type blog_post_type NOT NULL DEFAULT 'blog'; + +CREATE INDEX idx_blogs_business_post_type + ON blogs (business_id, post_type); diff --git a/database/migrations/026_store_specials.sql b/database/migrations/026_store_specials.sql new file mode 100644 index 0000000..2a18f95 --- /dev/null +++ b/database/migrations/026_store_specials.sql @@ -0,0 +1,37 @@ +-- Meshkee CMS — curated store specials (e.g. special sale, best sellers) + +CREATE TABLE store_specials ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT store_specials_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE +); + +CREATE INDEX idx_store_specials_business_id ON store_specials (business_id); + +CREATE TRIGGER store_specials_set_updated_at + BEFORE UPDATE ON store_specials + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE store_special_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + special_id BIGINT NOT NULL, + store_item_id BIGINT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT store_special_items_special_id_fkey + FOREIGN KEY (special_id) REFERENCES store_specials (id) ON DELETE CASCADE, + CONSTRAINT store_special_items_store_item_id_fkey + FOREIGN KEY (store_item_id) REFERENCES store_items (id) ON DELETE CASCADE, + CONSTRAINT store_special_items_unique UNIQUE (special_id, store_item_id) +); + +CREATE INDEX idx_store_special_items_special_id ON store_special_items (special_id); +CREATE INDEX idx_store_special_items_store_item_id ON store_special_items (store_item_id); diff --git a/database/migrations/027_contact_submissions.sql b/database/migrations/027_contact_submissions.sql new file mode 100644 index 0000000..fc23cea --- /dev/null +++ b/database/migrations/027_contact_submissions.sql @@ -0,0 +1,27 @@ +-- Website contact form submissions (per business) + +CREATE TABLE contact_submissions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + cell_number VARCHAR(20), + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT contact_submissions_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT contact_submissions_title_nonempty CHECK (char_length(trim(title)) > 0), + CONSTRAINT contact_submissions_name_nonempty CHECK (char_length(trim(name)) > 0), + CONSTRAINT contact_submissions_text_nonempty CHECK (char_length(trim(text)) > 0) +); + +CREATE INDEX idx_contact_submissions_business_created + ON contact_submissions (business_id, created_at DESC); + +CREATE TRIGGER contact_submissions_set_updated_at + BEFORE UPDATE ON contact_submissions + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); diff --git a/database/migrations/028_favorites.sql b/database/migrations/028_favorites.sql new file mode 100644 index 0000000..a3bcc0c --- /dev/null +++ b/database/migrations/028_favorites.sql @@ -0,0 +1,42 @@ +-- Customer product favorites (per business) + +CREATE TABLE favorites ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT favorites_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT favorites_user_id_fkey + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT favorites_product_id_fkey + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE, + CONSTRAINT favorites_business_user_product_unique + UNIQUE (business_id, user_id, product_id) +); + +CREATE INDEX idx_favorites_business_user_created + ON favorites (business_id, user_id, created_at DESC); + +CREATE INDEX idx_favorites_product_id + ON favorites (product_id); + +CREATE TRIGGER favorites_set_updated_at + BEFORE UPDATE ON favorites + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('Add own favorites', 'favorites.create', 'favorites', 'Add products to own favorites'), + ('Remove own favorites', 'favorites.delete', 'favorites', 'Remove products from own favorites') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('favorites.create', 'favorites.delete') +WHERE r.slug = 'customer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/029_brands.sql b/database/migrations/029_brands.sql new file mode 100644 index 0000000..1be3279 --- /dev/null +++ b/database/migrations/029_brands.sql @@ -0,0 +1,66 @@ +-- Meshkee CMS — product brands (per business) + +CREATE TABLE brands ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + name_en VARCHAR(255) NOT NULL, + name_fa VARCHAR(255), + image_media_id BIGINT, + about TEXT, + slug VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT brands_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE, + CONSTRAINT brands_image_media_id_fkey + FOREIGN KEY (image_media_id) REFERENCES media (id) ON DELETE SET NULL, + CONSTRAINT brands_business_slug_unique UNIQUE (business_id, slug), + CONSTRAINT brands_name_en_nonempty CHECK (char_length(trim(name_en)) > 0) +); + +CREATE INDEX idx_brands_business_id ON brands (business_id); +CREATE INDEX idx_brands_image_media_id ON brands (image_media_id); + +CREATE TRIGGER brands_set_updated_at + BEFORE UPDATE ON brands + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +ALTER TABLE products + ADD COLUMN brand_id BIGINT, + ADD CONSTRAINT products_brand_id_fkey + FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE SET NULL; + +CREATE INDEX idx_products_brand_id ON products (brand_id); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View brands', 'brands.read', 'brands', 'View product brands'), + ('Create brands', 'brands.create', 'brands', 'Create product brands'), + ('Update brands', 'brands.update', 'brands', 'Edit product brands'), + ('Delete brands', 'brands.delete', 'brands', 'Delete product brands') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'brands.%' +WHERE r.slug IN ('business_owner', 'owner', 'admin') +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('brands.read', 'brands.create', 'brands.update') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'brands.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/030_website_homepage.sql b/database/migrations/030_website_homepage.sql new file mode 100644 index 0000000..d5bfea3 --- /dev/null +++ b/database/migrations/030_website_homepage.sql @@ -0,0 +1,177 @@ +-- Meshkee CMS — website homepage widgets: category/brand groups and sliders + +-- --------------------------------------------------------------------------- +-- brands: user-defined list order +-- --------------------------------------------------------------------------- +ALTER TABLE brands + ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_brands_business_sort_order + ON brands (business_id, sort_order); + +-- --------------------------------------------------------------------------- +-- website category groups (curated category rows for the storefront) +-- --------------------------------------------------------------------------- +CREATE TABLE website_category_groups ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_category_groups_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE +); + +CREATE INDEX idx_website_category_groups_business_id + ON website_category_groups (business_id); + +CREATE TRIGGER website_category_groups_set_updated_at + BEFORE UPDATE ON website_category_groups + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE website_category_group_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + group_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT website_category_group_items_group_id_fkey + FOREIGN KEY (group_id) REFERENCES website_category_groups (id) ON DELETE CASCADE, + CONSTRAINT website_category_group_items_category_id_fkey + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE, + CONSTRAINT website_category_group_items_unique UNIQUE (group_id, category_id) +); + +CREATE INDEX idx_website_category_group_items_group_id + ON website_category_group_items (group_id); + +CREATE INDEX idx_website_category_group_items_category_id + ON website_category_group_items (category_id); + +-- --------------------------------------------------------------------------- +-- website brand groups (curated brand rows for the storefront) +-- --------------------------------------------------------------------------- +CREATE TABLE website_brand_groups ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_brand_groups_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE +); + +CREATE INDEX idx_website_brand_groups_business_id + ON website_brand_groups (business_id); + +CREATE TRIGGER website_brand_groups_set_updated_at + BEFORE UPDATE ON website_brand_groups + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE website_brand_group_items ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + group_id BIGINT NOT NULL, + brand_id BIGINT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT website_brand_group_items_group_id_fkey + FOREIGN KEY (group_id) REFERENCES website_brand_groups (id) ON DELETE CASCADE, + CONSTRAINT website_brand_group_items_brand_id_fkey + FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE CASCADE, + CONSTRAINT website_brand_group_items_unique UNIQUE (group_id, brand_id) +); + +CREATE INDEX idx_website_brand_group_items_group_id + ON website_brand_group_items (group_id); + +CREATE INDEX idx_website_brand_group_items_brand_id + ON website_brand_group_items (brand_id); + +-- --------------------------------------------------------------------------- +-- website sliders (multiple sliders per business, each with ordered slides) +-- --------------------------------------------------------------------------- +CREATE TABLE website_sliders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + business_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_sliders_business_id_fkey + FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE +); + +CREATE INDEX idx_website_sliders_business_id + ON website_sliders (business_id); + +CREATE TRIGGER website_sliders_set_updated_at + BEFORE UPDATE ON website_sliders + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE website_slider_slides ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slider_id BIGINT NOT NULL, + image_media_id BIGINT NOT NULL, + title VARCHAR(255), + link_url VARCHAR(2048), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT website_slider_slides_slider_id_fkey + FOREIGN KEY (slider_id) REFERENCES website_sliders (id) ON DELETE CASCADE, + CONSTRAINT website_slider_slides_image_media_id_fkey + FOREIGN KEY (image_media_id) REFERENCES media (id) ON DELETE RESTRICT +); + +CREATE INDEX idx_website_slider_slides_slider_id + ON website_slider_slides (slider_id); + +CREATE INDEX idx_website_slider_slides_image_media_id + ON website_slider_slides (image_media_id); + +CREATE TRIGGER website_slider_slides_set_updated_at + BEFORE UPDATE ON website_slider_slides + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- --------------------------------------------------------------------------- +-- permissions +-- --------------------------------------------------------------------------- +INSERT INTO permissions (name, slug, group_name, description) VALUES + ('View website widgets', 'website.read', 'website', 'View homepage category/brand groups and sliders'), + ('Manage website widgets', 'website.update', 'website', 'Create and edit homepage category/brand groups and sliders') +ON CONFLICT (slug) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug LIKE 'website.%' +WHERE r.slug IN ('business_owner', 'owner', 'admin') +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug IN ('website.read', 'website.update') +WHERE r.slug = 'editor' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.slug = 'website.read' +WHERE r.slug = 'viewer' +ON CONFLICT DO NOTHING; diff --git a/database/migrations/031_address_label.sql b/database/migrations/031_address_label.sql new file mode 100644 index 0000000..0d9d644 --- /dev/null +++ b/database/migrations/031_address_label.sql @@ -0,0 +1,4 @@ +-- User-defined label for saved addresses (e.g. home, office) + +ALTER TABLE addresses + ADD COLUMN IF NOT EXISTS label VARCHAR(100); diff --git a/database/migrations/032_address_postal_code_optional.sql b/database/migrations/032_address_postal_code_optional.sql new file mode 100644 index 0000000..e65254e --- /dev/null +++ b/database/migrations/032_address_postal_code_optional.sql @@ -0,0 +1,7 @@ +-- Postal code is optional for saved addresses + +ALTER TABLE addresses + DROP CONSTRAINT IF EXISTS addresses_postal_code_nonempty; + +ALTER TABLE addresses + ALTER COLUMN postal_code DROP NOT NULL; diff --git a/database/migrations/033_business_favicon.sql b/database/migrations/033_business_favicon.sql new file mode 100644 index 0000000..594ce6f --- /dev/null +++ b/database/migrations/033_business_favicon.sql @@ -0,0 +1,17 @@ +-- Favicon generated from business logo for dashboards and storefront + +ALTER TABLE businesses + ADD COLUMN IF NOT EXISTS favicon_media_id BIGINT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'businesses_favicon_media_id_fkey' + ) THEN + ALTER TABLE businesses + ADD CONSTRAINT businesses_favicon_media_id_fkey + FOREIGN KEY (favicon_media_id) REFERENCES media (id) ON DELETE SET NULL; + END IF; +END $$; diff --git a/database/seed.sh b/database/seed.sh new file mode 100755 index 0000000..f39e9f7 --- /dev/null +++ b/database/seed.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SEEDS_DIR="$ROOT_DIR/database/seeds" +CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}" +DB_USER="${POSTGRES_USER:-meshkee}" +DB_NAME="${POSTGRES_DB:-meshkee_cms}" + +"$ROOT_DIR/database/wait-for-postgres.sh" + +run_seed() { + local file="$1" + echo "→ Seeding $(basename "$file")" + docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file" +} + +if [[ $# -gt 0 ]]; then + run_seed "$1" +else + for file in "$SEEDS_DIR"/*.sql; do + [[ -f "$file" ]] || continue + run_seed "$file" + done +fi + +echo "Seed complete." diff --git a/database/seeds/001_sample_data.sql b/database/seeds/001_sample_data.sql new file mode 100644 index 0000000..d0d4e04 --- /dev/null +++ b/database/seeds/001_sample_data.sql @@ -0,0 +1,183 @@ +-- Sample seed data for local development / DataGrip testing +-- Password for all users: password +-- +-- User types: +-- 1 Ali — super_admin +-- 2 Reza — business_owner (Meshkee Demo Shop) +-- 3 Sara — business_owner (Creative Studio) +-- 4 Mina — customer on shop-a.local +-- 5 Amir — customer on studio-b.local + +INSERT INTO users (id, cell_number, password_hash, email, first_name, last_name, cell_verified_at) VALUES + (1, '+989121111111', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ali@meshkee.demo', 'Ali', 'Hassani', NOW()), + (2, '+989122222222', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'reza@shop.demo', 'Reza', 'Ahmadi', NOW()), + (3, '+989123333333', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'sara@studio.demo', 'Sara', 'Karimi', NOW()), + (4, '+989124444444', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'mina@customer.demo', 'Mina', 'Salehi', NOW()), + (5, '+989125555555', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'amir@customer.demo', 'Amir', 'Jafari', NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO businesses (id, name, name_fa, about, slug, description) VALUES + (1, 'Meshkee Demo Shop', 'فروشگاه دمو مشکی', 'فروشگاه آنلاین نمونه برای تست سیستم', 'meshkee-demo-shop', 'Sample e-commerce business'), + (2, 'Creative Studio', 'استودیو خلاق', 'آژانس طراحی و برندینگ', 'creative-studio', 'Design and branding agency') +ON CONFLICT (id) DO NOTHING; + +-- System business categories are seeded in 005_business_categories.sql + +INSERT INTO domains (id, business_id, host, is_primary, is_verified, verified_at, ssl_enabled) VALUES + (1, 1, 'shop-a.local', TRUE, TRUE, NOW(), FALSE), + (2, 1, 'www.shop-a.local', FALSE, TRUE, NOW(), FALSE), + (3, 2, 'studio-b.local', TRUE, TRUE, NOW(), FALSE) +ON CONFLICT (id) DO NOTHING; + +-- Roles +INSERT INTO user_roles (user_id, role_id) +SELECT 1, r.id FROM roles r WHERE r.slug = 'super_admin' +ON CONFLICT (user_id, role_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id) +SELECT 2, r.id FROM roles r WHERE r.slug = 'business_owner' +ON CONFLICT (user_id, role_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id) +SELECT 3, r.id FROM roles r WHERE r.slug = 'business_owner' +ON CONFLICT (user_id, role_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id) +SELECT 4, r.id FROM roles r WHERE r.slug = 'customer' +ON CONFLICT (user_id, role_id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id) +SELECT 5, r.id FROM roles r WHERE r.slug = 'customer' +ON CONFLICT (user_id, role_id) DO NOTHING; + +-- Business owners (assigned by super admin) +INSERT INTO business_users (id, business_id, user_id, is_owner) VALUES + (1, 1, 2, TRUE), + (2, 2, 3, TRUE) +ON CONFLICT (id) DO NOTHING; + +-- Team staff: editor on business 1 (invited by business owner) +INSERT INTO users (id, cell_number, password_hash, email, first_name, last_name, cell_verified_at) VALUES + (6, '+989126666667', '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'editor@shop.demo', 'Nima', 'Editori', NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO business_users (id, business_id, user_id, is_owner, role_id, invited_by) +SELECT 3, 1, 6, FALSE, r.id, 2 +FROM roles r WHERE r.slug = 'editor' +ON CONFLICT (id) DO NOTHING; + +INSERT INTO user_roles (user_id, role_id) +SELECT 6, r.id FROM roles r WHERE r.slug = 'business_staff' +ON CONFLICT (user_id, role_id) DO NOTHING; + +-- Website customers +INSERT INTO business_customers (id, business_id, user_id) VALUES + (1, 1, 4), + (2, 2, 5) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO media ( + id, business_id, uploaded_by, media_type, storage_disk, storage_path, public_url, + file_name, original_file_name, mime_type, file_size_bytes, width, height, duration_seconds, alt_text +) VALUES + (1, 1, 2, 'image', 'local', '/uploads/shop/hero-phone.jpg', 'https://cdn.meshkee.demo/shop/hero-phone.jpg', 'hero-phone.jpg', 'hero-phone.jpg', 'image/jpeg', 245000, 1200, 800, NULL, 'Smartphone hero'), + (2, 1, 2, 'image', 'local', '/uploads/shop/laptop.jpg', 'https://cdn.meshkee.demo/shop/laptop.jpg', 'laptop.jpg', 'laptop.jpg', 'image/jpeg', 198000, 1200, 800, NULL, 'Laptop product shot'), + (3, 1, 2, 'video', 'local', '/uploads/shop/unboxing.mp4', 'https://cdn.meshkee.demo/shop/unboxing.mp4', 'unboxing.mp4', 'unboxing.mp4', 'video/mp4', 5200000, NULL, NULL, 42.50, 'Product unboxing'), + (4, 2, 3, 'image', 'local', '/uploads/studio/brand-cover.jpg', 'https://cdn.meshkee.demo/studio/brand-cover.jpg', 'brand-cover.jpg', 'brand-cover.jpg', 'image/jpeg', 310000, 1600, 900, NULL, 'Branding project cover'), + (5, 2, 3, 'image', 'local', '/uploads/studio/web-ui.jpg', 'https://cdn.meshkee.demo/studio/web-ui.jpg', 'web-ui.jpg', 'web-ui.jpg', 'image/jpeg', 275000, 1440, 900, NULL, 'Web design mockup') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO categories (id, business_id, entity_type, parent_id, name, slug, description, sort_order) VALUES + (1, 1, 'product', NULL, 'Electronics', 'electronics', 'Electronic devices', 1), + (2, 1, 'product', 1, 'Phones', 'phones', 'Mobile phones', 1), + (3, 1, 'product', 1, 'Laptops', 'laptops', 'Laptops and notebooks', 2), + (4, 1, 'product', NULL, 'Accessories', 'accessories', 'Phone and laptop accessories', 2), + (5, 1, 'blog', NULL, 'Tutorials', 'tutorials', 'How-to guides', 1), + (6, 1, 'blog', NULL, 'News', 'news', 'Store news and updates', 2), + (7, 1, 'portfolio', NULL, 'Product Photography', 'product-photography', 'Commercial product shoots', 1), + (8, 2, 'product', NULL, 'Design Packages', 'design-packages', 'Service packages', 1), + (9, 2, 'blog', NULL, 'Case Studies', 'case-studies', 'Client success stories', 1), + (10, 2, 'blog', NULL, 'Design Tips', 'design-tips', 'Tips for better design', 2), + (11, 2, 'portfolio', NULL, 'Branding', 'branding', 'Logo and identity work', 1), + (12, 2, 'portfolio', NULL, 'Web Design', 'web-design', 'Websites and web apps', 2) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO products ( + id, business_id, title, slug, description, content, price, compare_at_price, sku, + stock_quantity, status, featured_media_id, sort_order, published_at +) VALUES + (1, 1, 'Meshkee X Phone', 'meshkee-x-phone', 'Flagship smartphone with OLED display.', + '{"blocks":[{"type":"paragraph","text":"6.5 inch OLED, 256GB storage."}]}', + 12990000, 14990000, 'MXP-001', 25, 'published', 1, 1, NOW()), + (2, 1, 'Meshkee Book Pro', 'meshkee-book-pro', 'Lightweight laptop for creators.', + '{"blocks":[{"type":"paragraph","text":"14 inch, 16GB RAM, 512GB SSD."}]}', + 28990000, NULL, 'MBP-001', 10, 'published', 2, 2, NOW()), + (3, 2, 'Brand Identity Package', 'brand-identity-package', 'Logo, color palette, and brand guidelines.', + '{"blocks":[{"type":"paragraph","text":"Includes 3 logo concepts."}]}', + 15000000, NULL, 'PKG-BRAND-01', NULL, 'published', 4, 1, NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO blogs ( + id, business_id, author_id, title, slug, excerpt, content, status, featured_media_id, published_at +) VALUES + (1, 1, 2, 'How to Choose the Right Phone', 'how-to-choose-phone', + 'A quick guide to picking your next smartphone.', + '{"blocks":[{"type":"heading","text":"Battery life"},{"type":"paragraph","text":"Look for 4000mAh or more."}]}', + 'published', 1, NOW()), + (2, 1, 2, 'Summer Sale Starts Next Week', 'summer-sale-next-week', + 'Up to 30% off on selected electronics.', + '{"blocks":[{"type":"paragraph","text":"Sale runs Monday through Sunday."}]}', + 'draft', NULL, NULL), + (3, 2, 3, 'Rebranding a Local Café', 'rebranding-local-cafe', + 'How we refreshed a neighborhood café brand.', + '{"blocks":[{"type":"paragraph","text":"We started with customer interviews."}]}', + 'published', 4, NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO portfolios ( + id, business_id, title, slug, description, content, client_name, project_url, + status, featured_media_id, sort_order, published_at +) VALUES + (1, 1, 'Phone Launch Campaign', 'phone-launch-campaign', + 'Product photos and video for Meshkee X Phone launch.', + '{"blocks":[{"type":"paragraph","text":"Shot in studio with 3 lighting setups."}]}', + 'Meshkee', 'https://shop-a.local/products/meshkee-x-phone', 'published', 1, 1, NOW()), + (2, 2, 'Nova Café Rebrand', 'nova-cafe-rebrand', + 'Full brand identity for Nova Café.', + '{"blocks":[{"type":"paragraph","text":"Logo, menu design, and signage."}]}', + 'Nova Café', 'https://novacafe.example.com', 'published', 4, 1, NOW()), + (3, 2, 'FinTech Dashboard UI', 'fintech-dashboard-ui', + 'Dashboard design for a financial startup.', + '{"blocks":[{"type":"paragraph","text":"Dark mode first design system."}]}', + 'PayFlow', 'https://payflow.example.com', 'published', 5, 2, NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO category_assignments (id, business_id, category_id, entity_type, entity_id) VALUES + (1, 1, 2, 'product', 1), + (2, 1, 3, 'product', 2), + (3, 2, 8, 'product', 3), + (4, 1, 5, 'blog', 1), + (5, 1, 6, 'blog', 2), + (6, 2, 9, 'blog', 3), + (7, 1, 7, 'portfolio', 1), + (8, 2, 11, 'portfolio', 2), + (9, 2, 12, 'portfolio', 3) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO media_attachments (id, business_id, media_id, entity_type, entity_id, sort_order, is_featured) VALUES + (1, 1, 3, 'product', 1, 1, FALSE), + (2, 2, 5, 'portfolio', 3, 1, FALSE) +ON CONFLICT (id) DO NOTHING; + +SELECT setval(pg_get_serial_sequence('users', 'id'), COALESCE((SELECT MAX(id) FROM users), 1)); +SELECT setval(pg_get_serial_sequence('businesses', 'id'), COALESCE((SELECT MAX(id) FROM businesses), 1)); +SELECT setval(pg_get_serial_sequence('domains', 'id'), COALESCE((SELECT MAX(id) FROM domains), 1)); +SELECT setval(pg_get_serial_sequence('business_users', 'id'), COALESCE((SELECT MAX(id) FROM business_users), 1)); +SELECT setval(pg_get_serial_sequence('business_customers', 'id'), COALESCE((SELECT MAX(id) FROM business_customers), 1)); +SELECT setval(pg_get_serial_sequence('media', 'id'), COALESCE((SELECT MAX(id) FROM media), 1)); +SELECT setval(pg_get_serial_sequence('categories', 'id'), COALESCE((SELECT MAX(id) FROM categories), 1)); +SELECT setval(pg_get_serial_sequence('products', 'id'), COALESCE((SELECT MAX(id) FROM products), 1)); +SELECT setval(pg_get_serial_sequence('blogs', 'id'), COALESCE((SELECT MAX(id) FROM blogs), 1)); +SELECT setval(pg_get_serial_sequence('portfolios', 'id'), COALESCE((SELECT MAX(id) FROM portfolios), 1)); +SELECT setval(pg_get_serial_sequence('category_assignments', 'id'), COALESCE((SELECT MAX(id) FROM category_assignments), 1)); +SELECT setval(pg_get_serial_sequence('media_attachments', 'id'), COALESCE((SELECT MAX(id) FROM media_attachments), 1)); diff --git a/database/seeds/002_super_admin_user.sql b/database/seeds/002_super_admin_user.sql new file mode 100644 index 0000000..3b606c7 --- /dev/null +++ b/database/seeds/002_super_admin_user.sql @@ -0,0 +1,35 @@ +-- Super admin account for production / staging +-- Cell: +989127004945 (09127004945) +-- Password: Ali2reza + +INSERT INTO users (cell_number, password_hash, email, first_name, last_name, cell_verified_at, is_active) +VALUES ( + '+989127004945', + '$2b$10$avrNkn5W5gWkZNrupUAXwePyj4FiwQM4H5hvHp.btPA1L0I0NqgAi', + 'ali@meshkee.app', + 'Ali', + 'Reza', + NOW(), + TRUE +) +ON CONFLICT (cell_number) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + is_active = TRUE, + cell_verified_at = COALESCE(users.cell_verified_at, NOW()); + +-- Ensure only super_admin as global role (removes business_owner/customer if present) +DELETE FROM user_roles ur +USING users u, roles r +WHERE ur.user_id = u.id + AND ur.role_id = r.id + AND u.cell_number = '+989127004945' + AND r.slug IN ('super_admin', 'business_owner', 'business_staff', 'customer'); + +INSERT INTO user_roles (user_id, role_id) +SELECT u.id, r.id +FROM users u +JOIN roles r ON r.slug = 'super_admin' +WHERE u.cell_number = '+989127004945' +ON CONFLICT (user_id, role_id) DO NOTHING; diff --git a/database/seeds/003_comments_and_expert_reviews.sql b/database/seeds/003_comments_and_expert_reviews.sql new file mode 100644 index 0000000..f7a33af --- /dev/null +++ b/database/seeds/003_comments_and_expert_reviews.sql @@ -0,0 +1,181 @@ +-- Sample comments and expert reviews for product 4 +-- Resolves business_id and approver from the product / business owner automatically. +-- Requires migrations 012_comments.sql and 013_expert_reviews.sql + +WITH product_ctx AS ( + SELECT + p.id AS product_id, + p.business_id, + owner.user_id AS owner_user_id + FROM products p + JOIN business_users owner + ON owner.business_id = p.business_id + AND owner.is_owner = TRUE + WHERE p.id = 4 +), +comment_rows AS ( + SELECT * + FROM (VALUES + ( + 1::bigint, + 'Mina Salehi'::varchar, + 'mina@customer.demo'::varchar, + 'Camera quality is outstanding, especially in low light. Very happy with the upgrade.'::text, + TRUE, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '3 days' + ), + ( + 2, + 'Arash Mohammadi', + 'arash@example.com', + 'Smooth performance and the display looks fantastic. Battery could last a bit longer though.', + TRUE, + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '2 days' + ), + ( + 3, + 'Leila Karimi', + 'leila@example.com', + 'Premium build and fast delivery from Sanihome. Setup was seamless.', + TRUE, + NOW() - INTERVAL '5 hours', + NOW() - INTERVAL '1 day' + ), + ( + 4, + 'Hossein Rahimi', + 'hossein@example.com', + 'Just placed my order — excited to try the new Pro model.', + FALSE, + NULL::timestamptz, + NOW() - INTERVAL '3 hours' + ), + ( + 5, + 'Nazanin Azizi', + NULL, + 'Does this model support dual SIM for Iran?', + FALSE, + NULL::timestamptz, + NOW() - INTERVAL '1 hour' + ) + ) AS rows( + id, + author_name, + author_email, + text, + is_approved, + approved_at, + created_at + ) +) +INSERT INTO comments ( + id, business_id, entity_type, entity_id, author_name, author_email, text, + is_approved, approved_at, approved_by, created_at +) +SELECT + r.id, + ctx.business_id, + 'product'::media_entity_type, + ctx.product_id, + r.author_name, + r.author_email, + r.text, + r.is_approved, + CASE WHEN r.is_approved THEN r.approved_at ELSE NULL END, + CASE WHEN r.is_approved THEN ctx.owner_user_id ELSE NULL END, + r.created_at +FROM comment_rows r +CROSS JOIN product_ctx ctx +ON CONFLICT (id) DO NOTHING; + +WITH product_ctx AS ( + SELECT + p.id AS product_id, + p.business_id, + owner.user_id AS owner_user_id + FROM products p + JOIN business_users owner + ON owner.business_id = p.business_id + AND owner.is_owner = TRUE + WHERE p.id = 4 +), +review_rows AS ( + SELECT * + FROM (VALUES + ( + 1::bigint, + 'MobileTech Review'::varchar, + 'reviews@mobiletech.demo'::varchar, + 9::smallint, + ARRAY['Excellent camera system', 'Top-tier performance', 'Premium display', 'Strong build quality']::text[], + ARRAY['High price point', 'No charger in box']::text[], + 'The iPhone 17 Pro remains a benchmark flagship. Photo and video capabilities are class-leading, and day-to-day performance is flawless for power users.'::text, + TRUE, + NOW() - INTERVAL '4 days', + NOW() - INTERVAL '5 days' + ), + ( + 2, + 'Gadget Iran', + 'editor@gadgetiran.demo', + 8, + ARRAY['Bright ProMotion display', 'Reliable iOS updates', 'Great video stabilization'], + ARRAY['Heavy for one-handed use', 'Storage upgrades are expensive'], + 'A compelling Pro model for creators and professionals. The camera and display are the main reasons to choose it over the standard line.', + TRUE, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '3 days' + ), + ( + 3, + 'PhoneLab', + 'lab@phonelab.demo', + 7, + ARRAY['Fast A-series chip', 'Solid battery for its class', 'Excellent ecosystem integration'], + ARRAY['Incremental design changes', 'Pro price without major leaps for casual users'], + 'A polished flagship that makes sense for Apple loyalists and mobile photographers, though casual upgraders may find better value elsewhere.', + FALSE, + NULL::timestamptz, + NOW() - INTERVAL '6 hours' + ) + ) AS rows( + id, + author_name, + author_email, + rate, + positive_points, + negative_points, + text, + is_approved, + approved_at, + created_at + ) +) +INSERT INTO expert_reviews ( + id, business_id, product_id, author_name, author_email, rate, + positive_points, negative_points, text, + is_approved, approved_at, approved_by, created_at +) +SELECT + r.id, + ctx.business_id, + ctx.product_id, + r.author_name, + r.author_email, + r.rate, + r.positive_points, + r.negative_points, + r.text, + r.is_approved, + CASE WHEN r.is_approved THEN r.approved_at ELSE NULL END, + CASE WHEN r.is_approved THEN ctx.owner_user_id ELSE NULL END, + r.created_at +FROM review_rows r +CROSS JOIN product_ctx ctx +ON CONFLICT (id) DO NOTHING; + +SELECT setval(pg_get_serial_sequence('comments', 'id'), COALESCE((SELECT MAX(id) FROM comments), 1)); +SELECT setval(pg_get_serial_sequence('expert_reviews', 'id'), COALESCE((SELECT MAX(id) FROM expert_reviews), 1)); diff --git a/database/seeds/004_iran_cities.sql b/database/seeds/004_iran_cities.sql new file mode 100644 index 0000000..b312a25 --- /dev/null +++ b/database/seeds/004_iran_cities.sql @@ -0,0 +1,113 @@ +-- Iran location reference data (country → provinces → provincial capitals) +-- Requires migration 015_cities.sql + +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) VALUES + (NULL, 'country', 'ایران', 'Iran', '98', 'iran', 1); + +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) +SELECT c.id, 'province', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order +FROM cities c +CROSS JOIN ( + VALUES + ('آذربایجان شرقی', 'East Azerbaijan', '041', 'east-azerbaijan', 1), + ('آذربایجان غربی', 'West Azerbaijan', '044', 'west-azerbaijan', 2), + ('اردبیل', 'Ardabil', '045', 'ardabil', 3), + ('اصفهان', 'Isfahan', '031', 'isfahan', 4), + ('البرز', 'Alborz', '026', 'alborz', 5), + ('ایلام', 'Ilam', '084', 'ilam', 6), + ('بوشهر', 'Bushehr', '077', 'bushehr', 7), + ('تهران', 'Tehran', '021', 'tehran-province', 8), + ('چهارمحال و بختیاری', 'Chaharmahal and Bakhtiari', '038', 'chaharmahal-bakhtiari', 9), + ('خراسان جنوبی', 'South Khorasan', '056', 'south-khorasan', 10), + ('خراسان رضوی', 'Razavi Khorasan', '051', 'razavi-khorasan', 11), + ('خراسان شمالی', 'North Khorasan', '058', 'north-khorasan', 12), + ('خوزستان', 'Khuzestan', '061', 'khuzestan', 13), + ('زنجان', 'Zanjan', '024', 'zanjan', 14), + ('سمنان', 'Semnan', '023', 'semnan', 15), + ('سیستان و بلوچستان', 'Sistan and Baluchestan', '054', 'sistan-baluchestan', 16), + ('فارس', 'Fars', '071', 'fars', 17), + ('قزوین', 'Qazvin', '028', 'qazvin', 18), + ('قم', 'Qom', '025', 'qom', 19), + ('کردستان', 'Kurdistan', '087', 'kurdistan', 20), + ('کرمان', 'Kerman', '034', 'kerman', 21), + ('کرمانشاه', 'Kermanshah', '083', 'kermanshah', 22), + ('کهگیلویه و بویراحمد', 'Kohgiluyeh and Boyer-Ahmad', '074', 'kohgiluyeh-boyer-ahmad', 23), + ('گلستان', 'Golestan', '017', 'golestan', 24), + ('گیلان', 'Gilan', '013', 'gilan', 25), + ('لرستان', 'Lorestan', '066', 'lorestan', 26), + ('مازندران', 'Mazandaran', '011', 'mazandaran', 27), + ('مرکزی', 'Markazi', '086', 'markazi', 28), + ('هرمزگان', 'Hormozgan', '076', 'hormozgan', 29), + ('همدان', 'Hamadan', '081', 'hamadan', 30), + ('یزد', 'Yazd', '035', 'yazd', 31) +) AS v(name_fa, name_en, landline_code, slug, sort_order) +WHERE c.slug = 'iran'; + +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) +SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order +FROM cities p +JOIN ( + VALUES + ('east-azerbaijan', 'تبریز', 'Tabriz', '041', 'tabriz', 1), + ('west-azerbaijan', 'ارومیه', 'Urmia', '044', 'urmia', 1), + ('ardabil', 'اردبیل', 'Ardabil', '045', 'ardabil-city', 1), + ('isfahan', 'اصفهان', 'Isfahan', '031', 'isfahan-city', 1), + ('alborz', 'کرج', 'Karaj', '026', 'karaj', 1), + ('ilam', 'ایلام', 'Ilam', '084', 'ilam-city', 1), + ('bushehr', 'بوشهر', 'Bushehr', '077', 'bushehr-city', 1), + ('tehran-province', 'تهران', 'Tehran', '021', 'tehran', 1), + ('chaharmahal-bakhtiari', 'شهرکرد', 'Shahrekord', '038', 'shahrekord', 1), + ('south-khorasan', 'بیرجند', 'Birjand', '056', 'birjand', 1), + ('razavi-khorasan', 'مشهد', 'Mashhad', '051', 'mashhad', 1), + ('north-khorasan', 'بجنورد', 'Bojnord', '058', 'bojnord', 1), + ('khuzestan', 'اهواز', 'Ahvaz', '061', 'ahvaz', 1), + ('zanjan', 'زنجان', 'Zanjan', '024', 'zanjan-city', 1), + ('semnan', 'سمنان', 'Semnan', '023', 'semnan-city', 1), + ('sistan-baluchestan', 'زاهدان', 'Zahedan', '054', 'zahedan', 1), + ('fars', 'شیراز', 'Shiraz', '071', 'shiraz', 1), + ('qazvin', 'قزوین', 'Qazvin', '028', 'qazvin-city', 1), + ('qom', 'قم', 'Qom', '025', 'qom-city', 1), + ('kurdistan', 'سنندج', 'Sanandaj', '087', 'sanandaj', 1), + ('kerman', 'کرمان', 'Kerman', '034', 'kerman-city', 1), + ('kermanshah', 'کرمانشاه', 'Kermanshah', '083', 'kermanshah-city', 1), + ('kohgiluyeh-boyer-ahmad', 'یاسوج', 'Yasuj', '074', 'yasuj', 1), + ('golestan', 'گرگان', 'Gorgan', '017', 'gorgan', 1), + ('gilan', 'رشت', 'Rasht', '013', 'rasht', 1), + ('lorestan', 'خرم‌آباد', 'Khorramabad', '066', 'khorramabad', 1), + ('mazandaran', 'ساری', 'Sari', '011', 'sari', 1), + ('markazi', 'اراک', 'Arak', '086', 'arak', 1), + ('hormozgan', 'بندرعباس', 'Bandar Abbas', '076', 'bandar-abbas', 1), + ('hamadan', 'همدان', 'Hamadan', '081', 'hamadan-city', 1), + ('yazd', 'یزد', 'Yazd', '035', 'yazd-city', 1) +) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order) + ON p.slug = v.province_slug +WHERE p.level = 'province'; + +-- Additional major cities +INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order) +SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order +FROM cities p +JOIN ( + VALUES + ('tehran-province', 'ری', 'Rey', '021', 'rey', 2), + ('tehran-province', 'شهریار', 'Shahriar', '021', 'shahriar', 3), + ('tehran-province', 'ورامین', 'Varamin', '021', 'varamin', 4), + ('isfahan', 'کاشان', 'Kashan', '031', 'kashan', 2), + ('isfahan', 'نجف‌آباد', 'Najafabad', '031', 'najafabad', 3), + ('fars', 'مرودشت', 'Marvdasht', '071', 'marvdasht', 2), + ('fars', 'جهرم', 'Jahrom', '071', 'jahrom', 3), + ('khuzestan', 'آبادان', 'Abadan', '061', 'abadan', 2), + ('khuzestan', 'دزفول', 'Dezful', '061', 'dezful', 3), + ('razavi-khorasan', 'نیشابور', 'Neyshabur', '051', 'neyshabur', 2), + ('razavi-khorasan', 'سبزوار', 'Sabzevar', '051', 'sabzevar', 3), + ('mazandaran', 'آمل', 'Amol', '011', 'amol', 2), + ('mazandaran', 'بابل', 'Babol', '011', 'babol', 3), + ('gilan', 'انزلی', 'Bandar Anzali', '013', 'bandar-anzali', 2), + ('east-azerbaijan', 'مراغه', 'Maragheh', '041', 'maragheh', 2), + ('kerman', 'رفسنجان', 'Rafsanjan', '034', 'rafsanjan', 2), + ('alborz', 'فردیس', 'Fardis', '026', 'fardis', 2) +) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order) + ON p.slug = v.province_slug +WHERE p.level = 'province'; + +SELECT setval(pg_get_serial_sequence('cities', 'id'), COALESCE((SELECT MAX(id) FROM cities), 1)); diff --git a/database/seeds/005_business_categories.sql b/database/seeds/005_business_categories.sql new file mode 100644 index 0000000..ebcf74a --- /dev/null +++ b/database/seeds/005_business_categories.sql @@ -0,0 +1,178 @@ +-- System business categories: retail, industry, and services (up to 3 levels) +-- Run after 001_sample_data.sql (replaces the minimal categories seeded there) + +DELETE FROM business_category_assignments; +DELETE FROM business_categories; + +-- --------------------------------------------------------------------------- +-- Level 1 — top-level industries +-- --------------------------------------------------------------------------- +INSERT INTO business_categories (parent_id, name, slug, description, sort_order) VALUES + (NULL, 'Retail & Shopping', 'retail-shopping', 'Physical and online retail businesses', 1), + (NULL, 'Manufacturing & Industry', 'manufacturing-industry', 'Production, factories, and industrial businesses', 2), + (NULL, 'Food & Beverage', 'food-beverage', 'Restaurants, food production, and beverage brands', 3), + (NULL, 'Professional Services', 'professional-services', 'Consulting, creative, and business services', 4), + (NULL, 'Technology & Digital', 'technology-digital', 'Software, IT, and digital businesses', 5), + (NULL, 'Health & Wellness', 'health-wellness', 'Healthcare, beauty, and fitness businesses', 6), + (NULL, 'Home & Living', 'home-living', 'Furniture, décor, and home improvement', 7), + (NULL, 'Automotive', 'automotive', 'Vehicle sales, parts, and services', 8); + +-- --------------------------------------------------------------------------- +-- Level 2 — sectors +-- --------------------------------------------------------------------------- +INSERT INTO business_categories (parent_id, name, slug, description, sort_order) +SELECT p.id, v.name, v.slug, v.description, v.sort_order +FROM business_categories p +JOIN ( + VALUES + -- Retail & Shopping + ('retail-shopping', 'Fashion & Apparel', 'fashion-apparel', 'Clothing, footwear, and fashion accessories', 1), + ('retail-shopping', 'Electronics & Tech Retail', 'electronics-retail', 'Consumer electronics and technology retail', 2), + ('retail-shopping', 'Grocery & Supermarket', 'grocery-supermarket', 'Supermarkets, grocery, and convenience stores', 3), + ('retail-shopping', 'Home & Furniture Retail', 'home-furniture-retail', 'Furniture, décor, and home goods stores', 4), + ('retail-shopping', 'Sports & Outdoors', 'sports-outdoors-retail', 'Sporting goods and outdoor equipment', 5), + ('retail-shopping', 'Jewelry & Accessories', 'jewelry-accessories', 'Jewelry, watches, and fashion accessories', 6), + ('retail-shopping', 'Books & Stationery', 'books-stationery', 'Bookstores, stationery, and office supplies', 7), + ('retail-shopping', 'E-commerce & Online', 'e-commerce-online', 'Online-only and omnichannel retail', 8), + + -- Manufacturing & Industry + ('manufacturing-industry', 'Textile & Apparel', 'textile-apparel-mfg', 'Garment, fabric, and textile production', 1), + ('manufacturing-industry', 'Food Processing', 'food-processing-mfg', 'Packaged food and beverage manufacturing', 2), + ('manufacturing-industry', 'Metal & Machinery', 'metal-machinery-mfg', 'Metalwork, machinery, and industrial equipment', 3), + ('manufacturing-industry', 'Chemicals & Materials', 'chemicals-materials', 'Chemicals, plastics, and raw materials', 4), + ('manufacturing-industry', 'Electronics Manufacturing', 'electronics-manufacturing', 'Electronic components and device manufacturing', 5), + ('manufacturing-industry', 'Packaging & Printing', 'packaging-printing', 'Packaging, labels, and commercial printing', 6), + + -- Food & Beverage + ('food-beverage', 'Restaurants & Cafés', 'restaurants-cafes', 'Dining, cafés, and hospitality', 1), + ('food-beverage', 'Bakery & Confectionery', 'bakery-confectionery', 'Bakeries, pastries, and sweets', 2), + ('food-beverage', 'Beverage Production', 'beverage-production', 'Juice, soft drinks, tea, and coffee production', 3), + ('food-beverage', 'Food Wholesale & Distribution', 'food-wholesale', 'Food distribution and wholesale supply', 4), + + -- Professional Services + ('professional-services', 'Design & Creative', 'design-creative', 'Design, branding, and creative agencies', 1), + ('professional-services', 'Consulting & Advisory', 'consulting-advisory', 'Business, legal, and management consulting', 2), + ('professional-services', 'Education & Training', 'education-training', 'Schools, courses, and training providers', 3), + ('professional-services', 'Marketing & Advertising', 'marketing-advertising', 'Marketing agencies and advertising services', 4), + + -- Technology & Digital + ('technology-digital', 'Software & IT Services', 'software-it-services', 'Software development and IT consulting', 1), + ('technology-digital', 'Digital Media & Content', 'digital-media', 'Media, content, and publishing platforms', 2), + ('technology-digital', 'Hardware & Devices', 'hardware-devices', 'Hardware products and device companies', 3), + + -- Health & Wellness + ('health-wellness', 'Beauty & Personal Care', 'beauty-personal-care', 'Salons, cosmetics, and personal care retail', 1), + ('health-wellness', 'Pharmacy & Medical Supply', 'pharmacy-medical-supply', 'Pharmacies and medical supply stores', 2), + ('health-wellness', 'Fitness & Sports Clubs', 'fitness-sports-clubs', 'Gyms, fitness studios, and sports clubs', 3), + + -- Home & Living + ('home-living', 'Furniture & Décor', 'furniture-decor', 'Furniture stores and interior décor', 1), + ('home-living', 'Building Materials', 'building-materials', 'Construction and building supply', 2), + ('home-living', 'Garden & Outdoor Living', 'garden-outdoor-living', 'Garden centers and outdoor living products', 3), + + -- Automotive + ('automotive', 'Vehicle Dealers', 'auto-dealers', 'Car, motorcycle, and vehicle dealerships', 1), + ('automotive', 'Parts & Service', 'auto-parts-service', 'Auto parts, repair, and maintenance services', 2) +) AS v(parent_slug, name, slug, description, sort_order) + ON p.slug = v.parent_slug +WHERE p.parent_id IS NULL; + +-- --------------------------------------------------------------------------- +-- Level 3 — specific business / store types +-- --------------------------------------------------------------------------- +INSERT INTO business_categories (parent_id, name, slug, description, sort_order) +SELECT p.id, v.name, v.slug, v.description, v.sort_order +FROM business_categories p +JOIN ( + VALUES + -- Fashion & Apparel + ('fashion-apparel', 'Women''s Clothing Store', 'womens-clothing-store', 'Retail stores focused on women''s apparel', 1), + ('fashion-apparel', 'Men''s Clothing Store', 'mens-clothing-store', 'Retail stores focused on men''s apparel', 2), + ('fashion-apparel', 'Children''s Clothing Store', 'children-clothing-store', 'Apparel for infants, kids, and teens', 3), + ('fashion-apparel', 'Footwear Store', 'footwear-store', 'Shoes, boots, and footwear retail', 4), + ('fashion-apparel', 'Luxury Fashion Boutique', 'luxury-fashion-boutique', 'High-end and designer fashion retail', 5), + + -- Electronics & Tech Retail + ('electronics-retail', 'Mobile & Accessories Store', 'mobile-accessories-store', 'Phones, tablets, and mobile accessories', 1), + ('electronics-retail', 'Computer & Laptop Store', 'computer-laptop-store', 'Computers, laptops, and peripherals', 2), + ('electronics-retail', 'Home Appliances Store', 'home-appliances-store', 'Large and small home appliances', 3), + ('electronics-retail', 'Consumer Electronics Store', 'consumer-electronics-store', 'General electronics and gadgets retail', 4), + + -- Grocery & Supermarket + ('grocery-supermarket', 'Supermarket & Hypermarket', 'supermarket-hypermarket', 'Large-format grocery and hypermarket chains', 1), + ('grocery-supermarket', 'Convenience Store', 'convenience-store', 'Neighborhood and convenience grocery', 2), + ('grocery-supermarket', 'Organic & Health Food Store', 'organic-health-food-store', 'Organic, natural, and health food retail', 3), + + -- E-commerce & Online + ('e-commerce-online', 'General E-commerce Store', 'general-e-commerce-store', 'Multi-category online retail stores', 1), + ('e-commerce-online', 'Online Fashion Store', 'online-fashion-store', 'Fashion-focused online retailers', 2), + ('e-commerce-online', 'Online Electronics Store', 'online-electronics-store', 'Electronics-focused online retailers', 3), + ('e-commerce-online', 'Marketplace Seller', 'marketplace-seller', 'Businesses selling primarily on marketplaces', 4), + + -- Textile & Apparel Manufacturing + ('textile-apparel-mfg', 'Garment Factory', 'garment-factory', 'Clothing and garment mass production', 1), + ('textile-apparel-mfg', 'Fabric & Textile Mill', 'fabric-textile-mill', 'Fabric weaving, knitting, and textile mills', 2), + ('textile-apparel-mfg', 'Leather Goods Manufacturing', 'leather-goods-manufacturing', 'Bags, belts, and leather products', 3), + + -- Food Processing + ('food-processing-mfg', 'Dairy Processing', 'dairy-processing', 'Milk, cheese, and dairy product manufacturing', 1), + ('food-processing-mfg', 'Meat Processing', 'meat-processing', 'Meat packing and processed meat products', 2), + ('food-processing-mfg', 'Snack Foods Manufacturing', 'snack-foods-manufacturing', 'Chips, nuts, and packaged snack production', 3), + + -- Metal & Machinery + ('metal-machinery-mfg', 'Industrial Machinery', 'industrial-machinery', 'Heavy machinery and industrial equipment', 1), + ('metal-machinery-mfg', 'Metal Fabrication', 'metal-fabrication', 'Sheet metal, welding, and metal parts', 2), + ('metal-machinery-mfg', 'Tools & Hardware Manufacturing', 'tools-hardware-manufacturing', 'Hand tools and hardware production', 3), + + -- Restaurants & Cafés + ('restaurants-cafes', 'Fast Food', 'fast-food', 'Quick-service and fast food restaurants', 1), + ('restaurants-cafes', 'Café & Coffee Shop', 'cafe-coffee-shop', 'Cafés, coffee shops, and tea houses', 2), + ('restaurants-cafes', 'Fine Dining Restaurant', 'fine-dining-restaurant', 'Full-service and upscale dining', 3), + ('restaurants-cafes', 'Bakery & Pastry Shop', 'bakery-pastry-shop', 'Retail bakeries and pastry shops', 4), + + -- Design & Creative + ('design-creative', 'Graphic Design Studio', 'graphic-design-studio', 'Visual design and print-focused studios', 1), + ('design-creative', 'Branding Agency', 'branding-agency', 'Brand strategy, identity, and positioning', 2), + ('design-creative', 'Web Design Agency', 'web-design-agency', 'Website and digital experience design', 3), + ('design-creative', 'Photography Studio', 'photography-studio', 'Commercial and studio photography', 4), + + -- Software & IT Services + ('software-it-services', 'Software Development', 'software-development', 'Custom software and application development', 1), + ('software-it-services', 'SaaS Company', 'saas-company', 'Software-as-a-service product companies', 2), + ('software-it-services', 'IT Consulting', 'it-consulting', 'IT strategy, integration, and support services', 3), + + -- Beauty & Personal Care + ('beauty-personal-care', 'Cosmetics Store', 'cosmetics-store', 'Makeup and skincare retail', 1), + ('beauty-personal-care', 'Hair & Beauty Salon', 'hair-beauty-salon', 'Salons and beauty service providers', 2), + ('beauty-personal-care', 'Perfume & Fragrance Store', 'perfume-fragrance-store', 'Perfume and fragrance specialty retail', 3), + + -- Furniture & Décor + ('furniture-decor', 'Furniture Store', 'furniture-store', 'Home and office furniture retail', 1), + ('furniture-decor', 'Home Décor Store', 'home-decor-store', 'Decorative items and home accessories', 2), + ('furniture-decor', 'Lighting Store', 'lighting-store', 'Lamps, fixtures, and lighting retail', 3), + + -- Automotive + ('auto-dealers', 'Car Dealership', 'car-dealership', 'New and used passenger car dealers', 1), + ('auto-dealers', 'Motorcycle Dealer', 'motorcycle-dealer', 'Motorcycle and scooter dealerships', 2), + ('auto-parts-service', 'Auto Parts Store', 'auto-parts-store', 'Spare parts and accessories retail', 1), + ('auto-parts-service', 'Auto Repair & Service', 'auto-repair-service', 'Vehicle maintenance and repair workshops', 2) +) AS v(parent_slug, name, slug, description, sort_order) + ON p.slug = v.parent_slug; + +-- Demo business category assignments (by slug) +INSERT INTO business_category_assignments (business_id, category_id) +SELECT 1, c.id +FROM business_categories c +WHERE c.slug IN ('general-e-commerce-store', 'electronics-retail', 'retail-shopping') +ON CONFLICT DO NOTHING; + +INSERT INTO business_category_assignments (business_id, category_id) +SELECT 2, c.id +FROM business_categories c +WHERE c.slug IN ('branding-agency', 'graphic-design-studio', 'design-creative') +ON CONFLICT DO NOTHING; + +SELECT setval( + pg_get_serial_sequence('business_categories', 'id'), + COALESCE((SELECT MAX(id) FROM business_categories), 1) +); diff --git a/database/setup.sh b/database/setup.sh new file mode 100755 index 0000000..9a216e5 --- /dev/null +++ b/database/setup.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$ROOT_DIR" +docker compose down -v +docker compose up -d +"$ROOT_DIR/database/seed.sh" diff --git a/database/wait-for-postgres.sh b/database/wait-for-postgres.sh new file mode 100755 index 0000000..9701327 --- /dev/null +++ b/database/wait-for-postgres.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER="${POSTGRES_CONTAINER:-meshkee-postgres}" +DB_USER="${POSTGRES_USER:-meshkee}" +DB_NAME="${POSTGRES_DB:-meshkee_cms}" +MAX_ATTEMPTS="${WAIT_MAX_ATTEMPTS:-60}" +SLEEP_SECONDS="${WAIT_SLEEP_SECONDS:-1}" + +echo "Waiting for PostgreSQL in container '$CONTAINER'..." + +for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + if docker exec "$CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + if docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \ + "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'users'" \ + 2>/dev/null | grep -q 1; then + echo "PostgreSQL is ready." + exit 0 + fi + fi + + if [[ "$attempt" -eq "$MAX_ATTEMPTS" ]]; then + echo "PostgreSQL did not become ready within ${MAX_ATTEMPTS}s." >&2 + echo "Check: docker compose ps && docker compose logs postgres" >&2 + exit 1 + fi + + sleep "$SLEEP_SECONDS" +done diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..3b2225c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,12 @@ +# Production extras (optional). +# Base docker-compose.yml already binds Postgres/Redis to 127.0.0.1. +# Do not redeclare the same host ports here — Compose merges by appending +# and that causes "address already in use". +# +# Usage (same as base): +# docker compose up -d +# +# Or explicitly: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +services: {} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c33b0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + image: postgres:16-alpine + container_name: meshkee-postgres + restart: unless-stopped + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-meshkee} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-meshkee_secret} + POSTGRES_DB: ${POSTGRES_DB:-meshkee_cms} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meshkee} -d ${POSTGRES_DB:-meshkee_cms}"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: meshkee-redis + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_PORT:-6379}:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..6b4cebf --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,178 @@ +# Deploy Meshkee CMS API (Debian VM) + +Stack: Docker (Postgres + Redis) → Node build on server → PM2 → Nginx + Let's Encrypt. + +App path on server: `/opt/meshkee/app` + +API domain: `api.meshkee.com` → `https://api.meshkee.com/api/v1` + +> **Note:** Until the Git remote is accessible from the VM (deploy key / credentials), updates can be synced with `rsync` from your laptop. Pin `sharp@0.33.5` — this VM CPU lacks x64-v2 required by sharp 0.35+. + +## Prerequisites + +- Debian VM with SSH access +- Domain `A` record pointing at the VM (for HTTPS) +- Git remote with this codebase (private repo → deploy key) +- Production secrets (JWT, Postgres password, S3 keys) + +## 1. Server packages + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y ca-certificates curl gnupg git nginx ufw + +# Docker +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +sudo usermod -aG docker "$USER" +# log out/in (or newgrp docker) so docker works without sudo + +# Node.js 20 LTS +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +sudo npm install -g pm2 + +# Certbot (after Nginx is installed) +sudo apt install -y certbot python3-certbot-nginx +``` + +Firewall: + +```bash +sudo ufw allow OpenSSH +sudo ufw allow 'Nginx Full' +sudo ufw --force enable +``` + +## 2. Clone the app + +```bash +sudo mkdir -p /opt/meshkee +sudo chown "$USER:$USER" /opt/meshkee +cd /opt/meshkee +git clone app +cd app +``` + +Private repo: create an SSH deploy key on the VM (`ssh-keygen -t ed25519 -C "meshkee-deploy"`), add the public key as a read-only deploy key on GitHub/GitLab, clone via SSH URL. + +## 3. Production env + +```bash +cp .env.example .env +nano .env # set strong secrets — never commit this file +``` + +Required production values: + +- Strong `POSTGRES_PASSWORD` and matching `DATABASE_URL` +- Long random `JWT_ACCESS_SECRET` / `JWT_REFRESH_SECRET` +- Real `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` +- `PORT=3000` +- `SMS_ENABLED` as needed + +## 4. Database + Redis + +```bash +cd /opt/meshkee/app +docker compose up -d +docker compose ps +``` + +First Postgres volume init runs SQL under `database/migrations/` automatically. + +Later schema updates: + +```bash +./database/migrate.sh +``` + +## 5. Build and run (on the server) + +```bash +cd /opt/meshkee/app +npm ci +npm run prisma:generate +npm run build +``` + +Production seed (super admin only — skip sample data): + +```bash +./database/seed.sh database/seeds/002_super_admin_user.sql +# optional reference data: +# ./database/seed.sh database/seeds/004_iran_cities.sql +# ./database/seed.sh database/seeds/005_business_categories.sql +``` + +Start with PM2: + +```bash +pm2 start ecosystem.config.js +pm2 save +pm2 startup # run the command it prints (usually with sudo) +``` + +Health check locally on the VM: + +```bash +curl -s http://127.0.0.1:3000/api/v1/ | head +# or hit a known public route such as tenant resolve +``` + +## 6. Nginx + HTTPS + +Create `/etc/nginx/sites-available/meshkee-api`: + +```nginx +server { + listen 80; + server_name api.example.com; # replace with your domain + + client_max_body_size 15M; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Enable and get a certificate: + +```bash +sudo ln -sf /etc/nginx/sites-available/meshkee-api /etc/nginx/sites-enabled/ +sudo rm -f /etc/nginx/sites-enabled/default +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d api.example.com +``` + +API base URL: `https://api.example.com/api/v1` + +## Ongoing updates + +```bash +cd /opt/meshkee/app +git pull +./database/migrate.sh # if there are new SQL migrations +npm ci +npm run prisma:generate +npm run build +pm2 restart meshkee-api +``` + +## Useful commands + +```bash +pm2 status +pm2 logs meshkee-api +docker compose logs -f postgres +``` diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md new file mode 100644 index 0000000..88874bc --- /dev/null +++ b/docs/PROJECT_CONTEXT.md @@ -0,0 +1,601 @@ +# Meshkee CMS API — Project Context + +> Living reference for developers and AI assistants working on this codebase. +> Last updated: July 2026 + +## What This Project Is + +**Meshkee CMS API** (`meshkee-cms-api`) is a multi-tenant backend for Meshkee business websites. Each business gets its own domain, content (products, categories, media), team members, and customer registrations. Platform super admins manage businesses and domains; business owners and staff manage per-business content through a permission-based dashboard. + +**API base URL:** `/api/v1` + +--- + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| Runtime | Node.js, TypeScript (ES2021, strict) | +| Framework | NestJS 11 | +| ORM | Prisma 6 (`prisma db pull` — schema is introspected, not migrated via Prisma) | +| Database | PostgreSQL 16 | +| Cache | Redis 7 (OTP storage) | +| Auth | JWT (access + refresh), Passport, bcrypt | +| Validation | class-validator + class-transformer | +| File storage | S3-compatible (Parmin), Sharp for image processing | + +--- + +## Architecture + +### Multi-tenancy model + +``` +Platform (super admin) + └── Business (tenant root) + ├── Domains (host → business resolution) + ├── Team members (BusinessUser + Role) + ├── Customers (BusinessCustomer) + ├── Categories (per entityType: product | blog | portfolio) + ├── Products + │ └── Store items (product variants — price & stock per variation combo) + ├── Media library + └── Settings (JSON) +``` + +- **Tenant resolution:** `GET /tenants/:host` resolves a domain to a business (public, no auth). +- **Business-scoped APIs:** Most CMS routes use `businesses/:businessId/...` with JWT + business permission checks. +- **Platform APIs:** User/business/domain management requires `super_admin` (checked in services). + +### Two "category" concepts + +| Concept | Table | Purpose | +|---------|-------|---------| +| **Content Category** | `categories` | Per-business taxonomy for products, blogs, portfolios | +| **Business Category** | `business_categories` | Platform-wide taxonomy classifying businesses (Retail, Creative, etc.) | + +Do not confuse them when reading or writing code. + +--- + +## Directory Structure + +``` +src/ +├── main.ts # Bootstrap, global prefix, pipes, interceptors +├── app.module.ts # Root module wiring +├── auth/ # JWT, OTP/SMS, permissions, guards, decorators +├── users/ # Super-admin user management +├── roles/ # Global + team role listing +├── business-admin/ # Super-admin business CRUD + domains +├── business-team/ # Per-business team invite/manage +├── tenant/ # Domain → business resolution (public) +├── domain-admin/ # Super-admin domain management +├── categories/ # Content categories, variations, technical forms +├── products/ # Products, variants, technical info +├── cart/ # Customer shopping cart +├── orders/ # Customer checkout + admin order management +├── media/ # Upload/list/update/delete media +├── storage/ # S3 driver abstraction +├── prisma/ # PrismaModule + PrismaService +├── redis/ # Redis client + OTP helpers +└── common/ # Shared interceptors (BigInt serialization) + +prisma/schema.prisma # Introspected from PostgreSQL (source of truth after migrations) +database/ +├── migrations/ # Raw SQL migrations (numbered) +├── seeds/ # Dev sample data + production super-admin +├── migrate.sh, seed.sh, setup.sh +postman/ # API collection for manual testing +``` + +### NestJS module pattern + +Each feature follows: `*.module.ts` → `*.controller.ts` → `*.service.ts` → `dto/` + +--- + +## Getting Started + +```bash +cp .env.example .env +docker compose up -d # Postgres (auto-runs migrations on first init) + Redis +./database/seed.sh # Sample dev data +npm install +npm run prisma:generate +npm run start:dev # http://localhost:3000/api/v1 +``` + +**Dev credentials** (after `001_sample_data.sql` seed): any seeded user, password `password`. + +Example super admin: `+989121111111` / `password` + +--- + +## Database & Migrations + +### Workflow + +1. Write a new SQL file in `database/migrations/` (e.g. `012_feature.sql`). +2. Apply via `./database/migrate.sh` or `docker exec` into Postgres. +3. Run `npm run prisma:pull` to sync `prisma/schema.prisma`. +4. Run `npm run prisma:generate` to regenerate the client. + +**Important:** This project does **not** use Prisma Migrate. SQL migrations are the authoritative schema history. Prisma schema is maintained separately via introspection. + +### Migration files + +| File | Purpose | +|------|---------| +| `001_initial_schema.sql` | Users, businesses, domains | +| `002_phone_permissions_content_media.sql` | RBAC, media, products, blogs, portfolios | +| `003_categories.sql` | Content categories + assignments | +| `004_user_types_and_business_members.sql` | Super admin, business owner, customer roles | +| `005_business_team_roles.sql` | Team roles (admin, editor, viewer) | +| `006_business_categories.sql` | Platform business category taxonomy | +| `006_user_profile.sql` | User profile JSON | +| `007_business_i18n_fields.sql` | Business Persian name | +| `007_domain_expiry_active.sql` | Domain expiry/active flags | +| `008_remove_name_en.sql` | Schema cleanup | +| `009_categories_name_fa.sql` | Category `name_fa` | +| `010_category_variations.sql` | Category variations, product variants (store items) | +| `011_category_technical_forms.sql` | Technical forms + product values | +| `012_comments.sql` | Product comments | +| `013_expert_reviews.sql` | Expert reviews | +| `014_addresses.sql` | User/business street addresses | +| `015_business_profile.sql` | Business profile fields | +| `015_cities.sql` | Location cities (country → province → city) | +| `016_product_variation_values.sql` | Product-level variation option selections (input for store items) | +| `017_product_variant_festival.sql` | Festival flag on store items | +| `018_product_variant_reward_points.sql` | Reward points on store items | +| `019_cart_and_orders.sql` | Shopping cart, orders, order items + order permissions | +| `020_store_items_and_variants.sql` | `store_items` + `store_item_variants` (replaces `product_variants`) | +| `030_website_homepage.sql` | Website category/brand groups, sliders, brand `sort_order` | + +Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations. + +--- + +## Domain Model + +### Enums + +| Enum | Values | +|------|--------| +| `MediaEntityType` | `product`, `blog`, `portfolio` | +| `ContentStatus` | `draft`, `published`, `archived` | +| `VariationType` | `color`, `size`, `custom` | +| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` | +| `OrderSource` | `website`, `admin` | +| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` | +| `MediaType` | `image`, `video` | + +### Core relationships + +``` +Business 1──* Domain +Business 1──* Category (entityType: product|blog|portfolio) +Business 1──* Product +Business 1──* Media + +Category 1──* CategoryVariation 1──* CategoryVariationOption +Category 1──0..1 CategoryTechnicalForm 1──* CategoryTechnicalFormField +CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption + +Product *──0..1 Category (via CategoryAssignment) +Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers) +Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers) +Product 1──0..1 StoreItem (one shop listing per product) +StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo) +StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption +Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField + +Business 1──* Cart (per customer) 1──* CartItem → ProductVariant +Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order) +User 1──* Cart, Order (as customer) + +Media 1──* MediaAttachment (polymorphic: entityType + entityId) +``` + +### Schema gap: blogs & portfolios + +SQL migrations create `blogs` and `portfolios` tables and seed data populates them. Permissions exist (`blogs.*`, `portfolios.*`). However: + +- No Prisma models for Blog/Portfolio +- No NestJS modules or API endpoints + +Categories and media attachments already support `blog` and `portfolio` entity types — infrastructure is ready, API is not. + +--- + +## API Reference + +All routes are prefixed with `/api/v1`. + +### Public + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/auth/register` | Customer registration by domain | +| POST | `/auth/login` | Cell + password | +| POST | `/auth/refresh` | Refresh token | +| POST | `/auth/send-otp` | Send OTP (Redis-backed) | +| POST | `/auth/verify-otp` | Verify OTP | +| GET | `/tenants/:host` | Resolve business from domain | +| GET | `/tenants/:host/store-specials` | Active store specials | +| GET | `/tenants/:host/website/category-groups` | Homepage category rows | +| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows | +| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides | + +### Authenticated (JWT) + +| Method | Path | Access | +|--------|------|--------| +| GET | `/auth/me` | Any user | +| PATCH | `/auth/profile` | Any user | +| POST | `/auth/change-password` | Any user | +| GET | `/roles?scope=global\|team` | Super admin / team.read | +| GET | `/business-categories` | Super admin or `business_categories.read` | + +### Super admin only (service-level check) + +| Module | Base path | +|--------|-----------| +| Users | `/users` | +| Businesses | `/businesses` | +| Domains | `/domains` | + +### Business-scoped (JWT + `BusinessPermissionGuard`) + +Pattern: `/businesses/:businessId/` + +| Module | Base path | Key permissions | +|--------|-----------|-----------------| +| Team | `/team` | `business.team.*` | +| Media | `/media` | `media.*` | +| Categories | `/categories` | `categories.*` | +| Products | `/products` | `products.*` | +| Website | `/website/category-groups`, `/website/brand-groups`, `/website/sliders` | `website.*` | + +#### Categories — notable sub-routes + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/categories/color-presets` | Predefined color palette | +| GET/PUT | `/categories/:id/variations` | Category variation options | +| GET/PUT | `/categories/:id/technical-form` | Technical form definition | + +#### Products — notable sub-routes + +| Method | Path | Description | +|--------|------|-------------| +| GET/PUT | `/products/:id/variations` | Which category variation options apply to this product | +| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` | +| GET/PUT | `/products/:id/technical-info` | Product technical data | + +#### Cart (customer — JWT, must be business customer) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/cart` | Get current user's cart | +| POST | `/cart/items` | Add store item variant to cart (`storeItemVariantId`, `quantity`) | +| PATCH | `/cart/items/:itemId` | Update cart item quantity | +| DELETE | `/cart/items/:itemId` | Remove cart item | +| DELETE | `/cart` | Clear cart | +| POST | `/cart/checkout` | Place order from cart (requires `addressId` or `shippingAddress`) | + +#### Orders + +| Method | Path | Access | Description | +|--------|------|--------|-------------| +| GET | `/orders` | Customer (own) or `orders.read` (all) | List orders | +| GET | `/orders/:orderId` | Customer (own) or `orders.read` | Order detail | +| POST | `/orders` | `orders.create` | Admin: create order for a customer | +| PATCH | `/orders/:orderId` | `orders.update` | Admin: update status / admin notes | + +--- + +## Auth & RBAC + +### Global user roles + +| Role slug | Dashboard | Notes | +|-----------|-----------|-------| +| `super_admin` | `super_admin` | Full platform access | +| `business_owner` | `business` | Assigned to business owners | +| `business_staff` | `business` | Legacy; team uses per-business roles | +| `customer` | `customer` | Website registrants | + +### Per-business team roles + +Assigned via `business_users.role_id`: + +| Role | Slug | Typical access | +|------|------|----------------| +| Owner | `isOwner=true` | All `business_owner` permissions | +| Admin | `admin` | Full content + team read | +| Editor | `editor` | Create/edit/publish content | +| Viewer | `viewer` | Read-only | + +### Permission groups (seeded) + +`business.*`, `domains.*`, `products.*`, `blogs.*`, `portfolios.*`, `media.*`, `categories.*`, `users.*`, `roles.manage`, `business.team.*`, `business_categories.*` + +Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` for content). + +### Guards & decorators + +```typescript +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +@RequireBusinessPermission('products.read') +``` + +- `JwtAuthGuard` — validates Bearer JWT (`type: 'access'`) +- `BusinessPermissionGuard` — checks permission for `businessId` route param +- `PermissionsService` — `isSuperAdmin()`, `hasBusinessPermission()` +- Super admins bypass business permission checks +- `@CurrentUser()` injects `AuthUser` into handlers + +### Auth flow notes + +- Registration resolves tenant by `domain` → creates/links user → assigns `customer` role +- OTP stored in Redis (`otp:{cellNumber}`), 5-min TTL; disabled when `SMS_ENABLED=false` +- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type` + +--- + +## Key Features + +### Products + +- CRUD with slug, status, featured media, gallery attachments +- One category per product (via `CategoryAssignment`) +- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }` +- i18n: Persian name in `content.nameFa`, summary in `description` + +### Category variations (product categories) + +- Types: `color` (preset palette), `size`, `custom` +- One color + one size per category (DB partial unique indexes) +- `PUT /categories/:id/variations` replaces all variations (delete-all + recreate) +- Color presets: `GET /categories/color-presets` (15 named colors with hex) + +### Store (shop listings) + +The **store** turns CMS products into purchasable items using a two-level model: + +``` +Product variations → which options this product offers (product_variation_values) +Store item → one shop listing per product (store_items) +Store item variants → purchasable SKUs with price/stock per combination (store_item_variants) +``` + +| Layer | Table | API | Purpose | +|-------|-------|-----|---------| +| Product variations | `product_variation_values` | `PUT /products/:id/variations` | Which category variation options apply to this product | +| Store item | `store_items` | `GET /store-items/by-product/:productId` | One listing per product in the shop | +| Store item variants | `store_item_variants` + `store_item_variant_selections` | `POST /store-items`, `PUT /store-items/sync` | Purchasable combinations with price & stock | + +**Store item variant fields** (`store_item_variants`): + +| Field | DB column | Notes | +|-------|-----------|-------| +| `sku` | `sku` | Optional merchant SKU | +| `price` | `price` | Selling price (`NUMERIC(12,2)`) | +| `compareAtPrice` | `compare_at_price` | Optional strike-through / was-price | +| `stockQuantity` | `stock_quantity` | Integer ≥ 0; `null` = untracked | +| `isActive` | `is_active` | Whether variant is available for purchase | +| `selections` | `store_item_variant_selections` | Exactly one `CategoryVariationOption` per category variation | + +**Rules:** + +- One `store_items` row per product per business. +- Create variants under the store item after setting product variation values. +- Each variant picks one option per variation; the combination must be unique per store item. +- Cart and orders reference `storeItemVariantId` (not the product directly). + +**Example — batch create variants for a product:** + +```json +POST /businesses/:businessId/store-items +{ + "productId": "1", + "items": [ + { + "selections": [ + { "variationId": "1", "optionId": "1" }, + { "variationId": "2", "optionId": "5" } + ], + "price": 99.99, + "stockQuantity": 10 + } + ] +} +``` + +**Example — update a variant:** + +```json +PATCH /businesses/:businessId/store-items/variants/:variantId +{ + "price": 89.99, + "stockQuantity": 25 +} +``` + +### Technical forms (product categories) + +- One form per product category +- Field types: `text`, `textarea`, `select`, `multi_select` +- `PUT /categories/:id/technical-form` — define/replace form fields +- `PUT /products/:id/technical-info` — fill product values (validated against category form) +- Product must have a category assignment + +**Example — define form:** + +```json +PUT /businesses/:businessId/categories/:categoryId/technical-form +{ + "fields": [ + { "label": "Weight", "type": "text", "isRequired": true }, + { "label": "Material", "type": "select", "options": ["Cotton", "Polyester"] }, + { "label": "Features", "type": "multi_select", "options": ["Waterproof", "Breathable"] } + ] +} +``` + +**Example — fill product data:** + +```json +PUT /businesses/:businessId/products/:productId/technical-info +{ + "values": [ + { "fieldKey": "weight", "value": "500g" }, + { "fieldKey": "material", "value": "cotton" }, + { "fieldKey": "features", "value": ["waterproof", "breathable"] } + ] +} +``` + +Field keys are auto-slugified from labels (e.g. `"Weight"` → `"weight"`). + +### Media + +- Multipart upload to S3 via Sharp processing +- Polymorphic attachments to products (and future blog/portfolio entities) +- Featured image on products via `featuredMediaId` + +--- + +## Coding Conventions + +### DTOs + +- `class-validator` decorators on all request bodies and query params +- `@Type(() => Number)` for query param coercion +- Slug format: `^[a-z0-9]+(?:-[a-z0-9]+)*$` +- Cell numbers: E.164 `^\+[1-9]\d{6,14}$` + +### Services + +- Inject `PrismaService`, `PermissionsService` +- Convert route param IDs with `BigInt(idRaw)` +- Private `assertPermission()` / `assertSuperAdmin()` helpers +- Use `$transaction` for multi-step writes +- **Replace semantics** for nested resources (variations, technical forms) — delete-all then recreate + +### Error handling + +Use Nest exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`, `ConflictException`, `UnauthorizedException`. + +### Serialization + +- Global `BigIntSerializerInterceptor` converts BigInt → Number in JSON responses +- Services use private `serialize()` methods for consistent output shapes +- IDs returned as strings in API responses + +### Global validation pipe (`main.ts`) + +```typescript +new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, +}) +``` + +--- + +## Environment Variables + +See `.env.example` for the full list. Key groups: + +| Group | Variables | +|-------|-----------| +| Database | `DATABASE_URL`, `POSTGRES_*` | +| Redis | `REDIS_URL`, `REDIS_HOST`, `REDIS_PORT` | +| API | `PORT` | +| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` | +| SMS | `SMS_ENABLED` | +| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | +| Media | `MEDIA_MAX_FILE_SIZE_MB` | + +--- + +## Implemented vs Planned + +### Implemented + +- Multi-tenant auth (register, login, OTP, profile) +- Super admin: users, businesses, domains, system business categories +- Business team management +- Media upload (S3 + Sharp) +- Categories (all entity types in DB; API supports `entityType` filter) +- Products CRUD +- Product variation values (which options a product offers) +- Store items / product variants (price, stock, SKU) +- Shopping cart + checkout + orders (customer + admin) +- Product technical info +- Category variations & technical forms +- Tenant resolution by domain +- RBAC with granular permissions + +### Planned / partial + +| Feature | DB | Permissions | API | Prisma | +|---------|----|-------------|-----|--------| +| Blogs | Yes | Yes | No | No model | +| Portfolios | Yes | Yes | No | No model | +| Customer dashboard | Partial | No | Register only | Yes | +| SMS provider | — | — | Stub | — | +| Store checkout (cart, orders) | Yes | Yes | Yes | Yes | +| Customer favorites | — | `favorites.*` seeded | No | No | + +--- + +## Testing + +**Postman collection:** `postman/Meshkee-CMS-Auth.postman_collection.json` + +Variables: `baseUrl`, `accessToken`, `refreshToken`, `domain`, `businessId`, `categoryId`, `productId`, `variantId`, `variationId`, `optionId`, `cartItemId`, `orderId` + +**Store workflow in Postman:** `Business Categories` → set variations → `Business Products` → create product → set product variations → create store item (variant) with price & stock → `Website - Cart` (login as customer) → add to cart → checkout → `Business Orders` (login as owner) to manage. + +--- + +## Common Tasks for Contributors + +### Add a new API feature + +1. Write SQL migration in `database/migrations/` +2. Apply migration, then `npm run prisma:pull && npm run prisma:generate` +3. Create module: `src//` with controller, service, DTOs +4. Register in `app.module.ts` +5. Add permissions to migration if business-scoped +6. Update this document + +### Add a business-scoped endpoint + +1. Controller: `@Controller('businesses/:businessId/...')` +2. Guards: `@UseGuards(JwtAuthGuard, BusinessPermissionGuard)` +3. Permission: `@RequireBusinessPermission('resource.action')` +4. Service: `assertPermission(businessId, actor.id, 'resource.action')` (defense in depth) + +### Add a nested replace resource (like variations) + +Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormService`: + +- `GET` returns current state +- `PUT` validates input, deletes all existing, recreates in a transaction + +--- + +## Related Files + +| Purpose | Path | +|---------|------| +| Prisma schema | `prisma/schema.prisma` | +| Env template | `.env.example` | +| Docker services | `docker-compose.yml` | +| Dev seed data | `database/seeds/001_sample_data.sql` | +| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` | diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..8bb4c07 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,16 @@ +module.exports = { + apps: [ + { + name: 'meshkee-api', + script: 'dist/main.js', + cwd: __dirname, + instances: 1, + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + }, + max_memory_restart: '512M', + time: true, + }, + ], +}; diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..34a910e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6487 @@ +{ + "name": "meshkee-cms-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meshkee-cms-api", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.1083.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@prisma/client": "^6.0.0", + "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "ioredis": "^5.4.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "sharp": "0.33.5" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/bcrypt": "^5.0.2", + "@types/express": "^5.0.0", + "@types/multer": "^2.2.0", + "@types/node": "^22.0.0", + "@types/passport-jwt": "^4.0.1", + "prisma": "^6.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.7.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.27.tgz", + "integrity": "sha512-3amNzoCVSKd7ah6l6lBQL4onwwJvqvam7FMoQBILrxtW5LB5ezh8gMSPuA4zJjKjoRzf9uoWdlzqv/84I52xZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.27.tgz", + "integrity": "sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.27", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.27.tgz", + "integrity": "sha512-wHYH6SVXVykhLzovUHtYor3Nl4SpIiITi7r9DQDaKYUD4hpRBx25W6N9eGuakT9Vd5tV/x6wmvQFWQZQwFB7eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.16.tgz", + "integrity": "sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1083.0.tgz", + "integrity": "sha512-3YOicHy6qjexsy4rHk5jPvtLgg6Ho1u+ycOWtJcVTAIMlMq32MRtnhllAyFSezhAXeBnnLLtKtAGXim7vV8oog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.16", + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/middleware-sdk-s3": "^3.972.62", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", + "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@aws-sdk/xml-builder": "^3.972.34", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.2", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", + "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", + "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", + "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", + "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", + "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", + "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", + "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/token-providers": "3.1083.0", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", + "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.62.tgz", + "integrity": "sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", + "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", + "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", + "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", + "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", + "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.23", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.23.tgz", + "integrity": "sha512-2V0Bf5jz0KXhUZk3eJi9GljIyqH04otwsE/mYLbqJR+X0iiYx+6bkNJ2Qz28uHNFj1cpHgimf9xDzHkqarie0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.2", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.27", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", + "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.27", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", + "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.27", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", + "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@smithy/core": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", + "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", + "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", + "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", + "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", + "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.8.tgz", + "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nypm": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", + "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c0bc86b --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "meshkee-cms-api", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "prisma:generate": "prisma generate", + "prisma:pull": "prisma db pull" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1083.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@prisma/client": "^6.0.0", + "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "ioredis": "^5.4.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "sharp": "0.33.5" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/bcrypt": "^5.0.2", + "@types/express": "^5.0.0", + "@types/multer": "^2.2.0", + "@types/node": "^22.0.0", + "@types/passport-jwt": "^4.0.1", + "prisma": "^6.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.7.0" + } +} diff --git a/postman/Meshkee-CMS-Auth.postman_collection.json b/postman/Meshkee-CMS-Auth.postman_collection.json new file mode 100644 index 0000000..7ef5797 --- /dev/null +++ b/postman/Meshkee-CMS-Auth.postman_collection.json @@ -0,0 +1,2644 @@ +{ + "info": { + "name": "Meshkee CMS API", + "description": "Meshkee CMS API collection.\n\n**Start here:** run `Login - Super Admin` first (saves accessToken).\n\n**Store setup workflow:**\n1. `Business Product Categories` → create product category → set category variations\n2. `Business Brands` → create brand (PNG logo via media upload)\n3. `Business Products` → create product (optional brandId) → set product variation values\n3. `Business Products` → create store item (variant) with price & stock\n4. `Website - Cart` → login as customer → add to cart → checkout\n5. `Business Orders` → login as owner → list/update orders\n\n**Blog workflow:**\n1. `Business Blog Categories` → list/create blog categories\n2. `Business Blogs` → create blog post (news/article/blog)\n3. `Website - Blogs` → list/get published posts, submit comments\n4. `Business Comments` or `Business Blogs` → moderate comments\n\n**Portfolio workflow:**\n1. `Business Portfolio Categories` → list/create portfolio categories\n2. `Business Portfolios` → create portfolio (title image 3:2, gallery, HTML body)\n3. `Website - Portfolios` → list/get published items, submit comments\n4. `Business Comments` or `Business Portfolios` → moderate comments\n\nSeed users (password: `password`):\n| Cell | Role |\n| +989121111111 | super_admin |\n| +989122222222 | business_owner |\n| +989123333333 | business_owner |\n| +989124444444 | customer |\n| +989125555555 | customer |", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "baseUrl", "value": "http://localhost:3000/api/v1" }, + { "key": "accessToken", "value": "" }, + { "key": "refreshToken", "value": "" }, + { "key": "domain", "value": "shop-a.local" }, + { "key": "businessId", "value": "1" }, + { "key": "categoryId", "value": "" }, + { "key": "brandId", "value": "" }, + { "key": "productId", "value": "" }, + { "key": "blogId", "value": "1" }, + { "key": "blogSlug", "value": "how-to-choose-phone" }, + { "key": "blogCategoryId", "value": "5" }, + { "key": "portfolioId", "value": "1" }, + { "key": "portfolioSlug", "value": "phone-launch-campaign" }, + { "key": "portfolioCategoryId", "value": "7" }, + { "key": "variantId", "value": "" }, + { "key": "storeItemVariantId", "value": "" }, + { "key": "variationId", "value": "" }, + { "key": "optionId", "value": "" }, + { "key": "commentId", "value": "" }, + { "key": "expertReviewId", "value": "" }, + { "key": "memberId", "value": "" }, + { "key": "ownerUserId", "value": "2" }, + { "key": "countryId", "value": "" }, + { "key": "provinceId", "value": "" }, + { "key": "cityId", "value": "" }, + { "key": "cartItemId", "value": "" }, + { "key": "orderId", "value": "" }, + { "key": "categoryGroupId", "value": "" }, + { "key": "brandGroupId", "value": "" }, + { "key": "sliderId", "value": "" }, + { "key": "specialId", "value": "" } + ], + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Register (customer on website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.accessToken) pm.collectionVariables.set('accessToken', json.accessToken);", + " if (json.refreshToken) pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989126666666\",\n \"password\": \"password123\",\n \"firstName\": \"New\",\n \"lastName\": \"Customer\",\n \"email\": \"new@example.com\",\n \"domain\": \"{{domain}}\"\n}" + }, + "url": "{{baseUrl}}/auth/register" + } + }, + { + "name": "Login - Super Admin", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989121111111\",\n \"password\": \"password\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Login - Business Owner", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989122222222\",\n \"password\": \"password\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Login - Customer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"password\": \"password\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Me (current user)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/auth/me" + } + }, + { + "name": "Refresh token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + }, + "url": "{{baseUrl}}/auth/refresh" + } + }, + { + "name": "Send OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\"\n}" + }, + "url": "{{baseUrl}}/auth/send-otp" + } + }, + { + "name": "Verify OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"code\": \"123456\"\n}" + }, + "url": "{{baseUrl}}/auth/verify-otp" + } + } + ] + }, + { + "name": "Reference Data - Cities", + "item": [ + { + "name": "List countries", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('countryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=country", + "host": ["{{baseUrl}}"], + "path": ["cities"], + "query": [{ "key": "level", "value": "country" }] + } + } + }, + { + "name": "List Iran provinces", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('provinceId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=province&parentSlug=iran", + "host": ["{{baseUrl}}"], + "path": ["cities"], + "query": [ + { "key": "level", "value": "province" }, + { "key": "parentSlug", "value": "iran" } + ] + } + } + }, + { + "name": "List cities in Tehran province", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const tehran = json.items?.find((item) => item.slug === 'tehran');", + " if (tehran?.id) pm.collectionVariables.set('cityId', tehran.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentSlug=tehran-province", + "host": ["{{baseUrl}}"], + "path": ["cities"], + "query": [ + { "key": "level", "value": "city" }, + { "key": "parentSlug", "value": "tehran-province" } + ] + } + } + }, + { + "name": "List cities by province ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentId={{provinceId}}", + "host": ["{{baseUrl}}"], + "path": ["cities"], + "query": [ + { "key": "level", "value": "city" }, + { "key": "parentId", "value": "{{provinceId}}" } + ] + } + } + }, + { + "name": "Get city by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/cities/{{cityId}}" + } + } + ] + }, + { + "name": "Website - Comments", + "item": [ + { + "name": "Submit comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"product\",\n \"entityId\": \"{{productId}}\",\n \"authorName\": \"Website Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Great product! Would recommend.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=product&entityId={{productId}}", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "comments"], + "query": [ + { "key": "entityType", "value": "product" }, + { "key": "entityId", "value": "{{productId}}" } + ] + } + } + }, + { + "name": "Submit comment on blog (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"blog\",\n \"entityId\": \"{{blogId}}\",\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Great article, thanks for sharing!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for blog (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=blog&entityId={{blogId}}", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "comments"], + "query": [ + { "key": "entityType", "value": "blog" }, + { "key": "entityId", "value": "{{blogId}}" } + ] + } + } + }, + { + "name": "Submit comment on portfolio (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"portfolio\",\n \"entityId\": \"{{portfolioId}}\",\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Beautiful work!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for portfolio (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=portfolio&entityId={{portfolioId}}", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "comments"], + "query": [ + { "key": "entityType", "value": "portfolio" }, + { "key": "entityId", "value": "{{portfolioId}}" } + ] + } + } + } + ] + }, + { + "name": "Website - Blogs", + "item": [ + { + "name": "List published blogs (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('blogSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/blogs?page=1&pageSize=12", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "blogs"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "12" }, + { "key": "type", "value": "", "disabled": true }, + { "key": "categoryId", "value": "", "disabled": true }, + { "key": "title", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Get published blog by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogSlug}}" + } + }, + { + "name": "List blog comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + }, + { + "name": "Submit blog comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Very helpful post!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + } + ] + }, + { + "name": "Website - Portfolios", + "item": [ + { + "name": "List published portfolios (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('portfolioSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "portfolios"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "12" }, + { "key": "categoryId", "value": "", "disabled": true }, + { "key": "title", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Get published portfolio by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" + } + }, + { + "name": "List portfolio comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + }, + { + "name": "Submit portfolio comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Impressive project!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + } + ] + }, + { + "name": "Website - Expert Reviews", + "item": [ + { + "name": "Submit expert review (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.review?.id) pm.collectionVariables.set('expertReviewId', json.review.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\",\n \"authorName\": \"Expert Reviewer\",\n \"authorEmail\": \"expert@example.com\",\n \"rate\": 8,\n \"positivePoints\": [\"Build quality\", \"Great battery life\", \"Comfortable fit\"],\n \"negativePoints\": [\"Pricey\", \"Limited colors\"],\n \"text\": \"Solid product overall with a few trade-offs worth considering.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/expert-reviews" + } + }, + { + "name": "List approved expert reviews (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/expert-reviews?productId={{productId}}", + "host": ["{{baseUrl}}"], + "path": ["tenants", "{{domain}}", "expert-reviews"], + "query": [{ "key": "productId", "value": "{{productId}}" }] + } + } + } + ] + }, + { + "name": "Super Admin - Businesses", + "item": [ + { + "name": "List business categories", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/business-categories" + } + }, + { + "name": "Search users (owner picker, min 2 chars)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('ownerUserId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/users/search?q=re&limit=20", + "host": ["{{baseUrl}}"], + "path": ["users", "search"], + "query": [ + { "key": "q", "value": "re", "description": "min 2 characters" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Search businesses (min 2 chars)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('businessId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/search?q=sh&limit=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "search"], + "query": [ + { "key": "q", "value": "sh", "description": "min 2 characters" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "List businesses", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses?page=1&pageSize=10", + "host": ["{{baseUrl}}"], + "path": ["businesses"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "10" }, + { "key": "name", "value": "", "disabled": true }, + { "key": "domain", "value": "", "disabled": true }, + { "key": "category", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "List business staff", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/staff" + } + }, + { + "name": "Get business", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}" + } + }, + { + "name": "Create business", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.id) pm.collectionVariables.set('businessId', json.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"nameFa\": \"فروشگاه جدید\",\n \"name\": \"New Shop\",\n \"about\": \"توضیحات کسب‌وکار\",\n \"slug\": \"new-shop\",\n \"categoryIds\": [1, 2],\n \"ownerUserId\": {{ownerUserId}}\n}" + }, + "url": "{{baseUrl}}/businesses" + } + }, + { + "name": "Update business", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"nameFa\": \"فروشگاه بروز شده\",\n \"name\": \"Updated Shop\",\n \"about\": \"About text updated\",\n \"categoryIds\": [2],\n \"ownerUserId\": {{ownerUserId}}\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}" + } + }, + { + "name": "Add domain to business", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"host\": \"new-shop.local\",\n \"isPrimary\": true\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/domains" + } + }, + { + "name": "Disable / enable business", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isActive\": false\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/disable" + } + }, + { + "name": "Delete business", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}" + } + } + ] + }, + { + "name": "Super Admin - Users", + "item": [ + { + "name": "List users", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/users?page=1&pageSize=24", + "host": ["{{baseUrl}}"], + "path": ["users"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "24" }, + { "key": "name", "value": "", "disabled": true }, + { "key": "cellNumber", "value": "", "disabled": true }, + { "key": "role", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create user (customer)", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989129999999\",\n \"password\": \"password123\",\n \"firstName\": \"New\",\n \"lastName\": \"Customer\",\n \"email\": \"customer@example.com\",\n \"businessId\": {{businessId}}\n}" + }, + "url": "{{baseUrl}}/users" + } + }, + { + "name": "Change user global role", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"roleSlug\": \"customer\"\n}" + }, + "url": "{{baseUrl}}/users/4/role" + } + } + ] + }, + { + "name": "Roles", + "item": [ + { + "name": "List global roles (super admin)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/roles?scope=global", + "host": ["{{baseUrl}}"], + "path": ["roles"], + "query": [{ "key": "scope", "value": "global" }] + } + } + }, + { + "name": "List team roles (business owner)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/roles?scope=team", + "host": ["{{baseUrl}}"], + "path": ["roles"], + "query": [{ "key": "scope", "value": "team" }] + } + } + } + ] + }, + { + "name": "Business Media", + "item": [ + { + "name": "List media", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/media?page=1&pageSize=24", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "media"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "24" }, + { "key": "mediaType", "value": "image", "disabled": true } + ] + } + } + }, + { + "name": "Upload media (multipart)", + "request": { + "method": "POST", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "body": { + "mode": "formdata", + "formdata": [ + { "key": "files", "type": "file", "src": [] }, + { "key": "files", "type": "file", "src": [], "disabled": true } + ] + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/media" + } + }, + { + "name": "Update media", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"altText\": \"Product photo\",\n \"caption\": \"Optional caption\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/media/1" + } + }, + { + "name": "Delete media", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/media/1" + } + } + ] + }, + { + "name": "Business Product Categories", + "item": [ + { + "name": "List product categories", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/categories?entityType=product", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "categories"], + "query": [{ "key": "entityType", "value": "product" }] + } + } + }, + { + "name": "Create product category", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.category?.id) pm.collectionVariables.set('categoryId', json.category.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"product\",\n \"name\": \"Electronics\",\n \"nameFa\": \"الکترونیک\",\n \"description\": \"Electronic devices\",\n \"parentId\": null\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories" + } + }, + { + "name": "Update product category", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Electronics Updated\",\n \"nameFa\": \"الکترونیک بروز شده\",\n \"description\": \"Updated description\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}" + } + }, + { + "name": "Delete product category", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}" + } + }, + { + "name": "List color presets", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/color-presets" + } + }, + { + "name": "Get category variations", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}/variations" + } + }, + { + "name": "Replace category variations", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const items = json.items || json;", + " if (Array.isArray(items) && items.length > 0) {", + " const first = items[0];", + " if (first.id) pm.collectionVariables.set('variationId', first.id);", + " if (first.options?.[0]?.id) pm.collectionVariables.set('optionId', first.options[0].id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"variations\": [\n {\n \"type\": \"color\",\n \"name\": \"Color\",\n \"values\": [\"Red\", \"Blue\", \"Black\"]\n },\n {\n \"type\": \"size\",\n \"name\": \"Size\",\n \"values\": [\"S\", \"M\", \"L\", \"XL\"]\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}/variations" + } + }, + { + "name": "Get category technical form", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}/technical-form" + } + }, + { + "name": "Replace category technical form", + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"fields\": [\n {\n \"label\": \"Weight\",\n \"type\": \"text\",\n \"isRequired\": true\n },\n {\n \"label\": \"Description\",\n \"type\": \"textarea\"\n },\n {\n \"label\": \"Material\",\n \"type\": \"select\",\n \"options\": [\"Cotton\", \"Polyester\", \"Wool\"]\n },\n {\n \"label\": \"Features\",\n \"type\": \"multi_select\",\n \"options\": [\"Waterproof\", \"Breathable\", \"UV Protection\"]\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories/{{categoryId}}/technical-form" + } + } + ] + }, + { + "name": "Business Blog Categories", + "item": [ + { + "name": "List blog categories", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogCategoryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/categories?entityType=blog", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "categories"], + "query": [{ "key": "entityType", "value": "blog" }] + } + } + }, + { + "name": "Create blog category", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.category?.id) pm.collectionVariables.set('blogCategoryId', json.category.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"blog\",\n \"name\": \"Tutorials\",\n \"nameFa\": \"آموزش\",\n \"description\": \"How-to guides and tips\",\n \"parentId\": null\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories" + } + } + ] + }, + { + "name": "Business Portfolio Categories", + "item": [ + { + "name": "List portfolio categories", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioCategoryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/categories?entityType=portfolio", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "categories"], + "query": [{ "key": "entityType", "value": "portfolio" }] + } + } + }, + { + "name": "Create portfolio category", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.category?.id) pm.collectionVariables.set('portfolioCategoryId', json.category.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"portfolio\",\n \"name\": \"Product Photography\",\n \"nameFa\": \"عکاسی محصول\",\n \"description\": \"Commercial product shoots\",\n \"parentId\": null\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/categories" + } + } + ] + }, + { + "name": "Business Brands", + "item": [ + { + "name": "List brands", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/brands?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "brands"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Create brand", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.brand?.id) pm.collectionVariables.set('brandId', json.brand.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"nameEn\": \"Acme\",\n \"nameFa\": \"اکمه\",\n \"imageMediaId\": \"1\",\n \"about\": \"Premium electronics brand founded in 2010.\",\n \"sortOrder\": 0\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/brands" + } + }, + { + "name": "Get brand", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/brands/{{brandId}}" + } + }, + { + "name": "Update brand", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"nameEn\": \"Acme Updated\",\n \"about\": \"Updated brand description.\",\n \"sortOrder\": 1\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/brands/{{brandId}}" + } + }, + { + "name": "Delete brand", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/brands/{{brandId}}" + } + } + ] + }, + { + "name": "Business Website", + "item": [ + { + "name": "Category Groups", + "item": [ + { + "name": "List category groups", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/website/category-groups?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "website", "category-groups"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Create category group", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.group?.id) pm.collectionVariables.set('categoryGroupId', json.group.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Shop by category\",\n \"sortOrder\": 0,\n \"isActive\": true,\n \"categoryIds\": [\"{{categoryId}}\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/category-groups" + } + }, + { + "name": "Get category group", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/category-groups/{{categoryGroupId}}" + } + }, + { + "name": "Update category group", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Featured categories\",\n \"sortOrder\": 0,\n \"categoryIds\": [\"{{categoryId}}\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/category-groups/{{categoryGroupId}}" + } + }, + { + "name": "Delete category group", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/category-groups/{{categoryGroupId}}" + } + } + ] + }, + { + "name": "Brand Groups", + "item": [ + { + "name": "List brand groups", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/website/brand-groups?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "website", "brand-groups"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Create brand group", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.group?.id) pm.collectionVariables.set('brandGroupId', json.group.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Our brands\",\n \"sortOrder\": 0,\n \"isActive\": true,\n \"brandIds\": [\"{{brandId}}\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/brand-groups" + } + }, + { + "name": "Get brand group", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/brand-groups/{{brandGroupId}}" + } + }, + { + "name": "Update brand group", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Featured brands\",\n \"sortOrder\": 0,\n \"brandIds\": [\"{{brandId}}\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/brand-groups/{{brandGroupId}}" + } + }, + { + "name": "Delete brand group", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/brand-groups/{{brandGroupId}}" + } + } + ] + }, + { + "name": "Sliders", + "item": [ + { + "name": "List sliders", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/website/sliders?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "website", "sliders"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Create slider", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.slider?.id) pm.collectionVariables.set('sliderId', json.slider.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Hero slider\",\n \"sortOrder\": 0,\n \"isActive\": true,\n \"slides\": [\n {\n \"imageMediaId\": \"1\",\n \"title\": \"Summer sale\",\n \"linkUrl\": \"https://example.com/sale\",\n \"isActive\": true\n },\n {\n \"imageMediaId\": \"2\",\n \"linkUrl\": \"https://example.com/new\",\n \"isActive\": true\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/sliders" + } + }, + { + "name": "Get slider", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/sliders/{{sliderId}}" + } + }, + { + "name": "Update slider", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Hero slider updated\",\n \"slides\": [\n {\n \"imageMediaId\": \"1\",\n \"title\": \"Updated slide\",\n \"linkUrl\": \"https://example.com\",\n \"isActive\": true\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/website/sliders/{{sliderId}}" + } + }, + { + "name": "Delete slider", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/website/sliders/{{sliderId}}" + } + } + ] + }, + { + "name": "Store Specials", + "item": [ + { + "name": "List store specials", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/store-specials?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "store-specials"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Create store special", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.special?.id) pm.collectionVariables.set('specialId', json.special.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Best sellers\",\n \"sortOrder\": 0,\n \"isActive\": true,\n \"storeItemIds\": []\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/store-specials" + } + }, + { + "name": "Get store special", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/store-specials/{{specialId}}" + } + }, + { + "name": "Update store special", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Special sale\",\n \"sortOrder\": 0,\n \"storeItemIds\": []\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/store-specials/{{specialId}}" + } + }, + { + "name": "Delete store special", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/store-specials/{{specialId}}" + } + } + ] + } + ] + }, + { + "name": "Business Products", + "item": [ + { + "name": "List products", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/products?page=1&pageSize=12", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "products"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "12" } + ] + } + } + }, + { + "name": "Create product", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.product?.id) pm.collectionVariables.set('productId', json.product.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Sample Product\",\n \"nameFa\": \"محصول نمونه\",\n \"summary\": \"Short summary\",\n \"descriptionHtml\": \"

Full description

\",\n \"categoryId\": \"{{categoryId}}\",\n \"brandId\": \"{{brandId}}\",\n \"featuredMediaId\": \"1\",\n \"galleryMediaIds\": [\"2\"],\n \"tags\": [\"sample\"],\n \"status\": \"published\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/products" + } + }, + { + "name": "Get product", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}" + } + }, + { + "name": "Update product", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Updated Product\",\n \"summary\": \"Updated summary\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}" + } + }, + { + "name": "Delete product", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}" + } + }, + { + "name": "Get product variation values", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}/variations" + } + }, + { + "name": "Replace product variation values", + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"selections\": [\n {\n \"variationId\": \"{{variationId}}\",\n \"optionIds\": [\"{{optionId}}\"]\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}/variations" + } + }, + { + "name": "Get store item by product", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/store-items/by-product/{{productId}}" + } + }, + { + "name": "Create store item variants (batch)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " const items = json.items || [];", + " if (items.length) {", + " pm.collectionVariables.set('variantId', items[0].id);", + " pm.collectionVariables.set('storeItemVariantId', items[0].id);", + " }", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\",\n \"items\": [\n {\n \"selections\": [\n { \"variationId\": \"{{variationId}}\", \"optionId\": \"{{optionId}}\" }\n ],\n \"price\": 99.99,\n \"stockQuantity\": 10\n }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/store-items" + } + }, + { + "name": "Update store item variant", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"price\": 89.99,\n \"stockQuantity\": 25\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/store-items/variants/{{storeItemVariantId}}" + } + }, + { + "name": "Delete store item variant", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/store-items/variants/{{storeItemVariantId}}" + } + }, + { + "name": "Get product technical info", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}/technical-info" + } + }, + { + "name": "Replace product technical info", + "request": { + "method": "PUT", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"values\": [\n { \"fieldKey\": \"weight\", \"value\": \"500g\" },\n { \"fieldKey\": \"description\", \"value\": \"High quality fabric with durable stitching.\" },\n { \"fieldKey\": \"material\", \"value\": \"cotton\" },\n { \"fieldKey\": \"features\", \"value\": [\"waterproof\", \"breathable\"] }\n ]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/products/{{productId}}/technical-info" + } + } + ] + }, + { + "name": "Business Blogs", + "item": [ + { + "name": "List blogs", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('blogSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/blogs?page=1&pageSize=12", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "blogs"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "12" }, + { "key": "status", "value": "", "disabled": true }, + { "key": "type", "value": "", "disabled": true }, + { "key": "categoryId", "value": "", "disabled": true }, + { "key": "title", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create blog", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.blog?.id) pm.collectionVariables.set('blogId', json.blog.id);", + " if (json.blog?.slug) pm.collectionVariables.set('blogSlug', json.blog.slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"How to Choose the Right Phone\",\n \"type\": \"article\",\n \"abstract\": \"A quick guide to picking your next smartphone.\",\n \"mainTextHtml\": \"

Battery life

Look for 4000mAh or more.

\\\"Phone\\\"\",\n \"categoryId\": \"{{blogCategoryId}}\",\n \"featuredMediaId\": \"1\",\n \"tags\": [\"phones\", \"guide\"],\n \"authorId\": \"{{ownerUserId}}\",\n \"status\": \"published\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/blogs" + } + }, + { + "name": "Get blog", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/blogs/{{blogId}}" + } + }, + { + "name": "Update blog", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Updated Blog Title\",\n \"type\": \"news\",\n \"abstract\": \"Updated abstract.\",\n \"mainTextHtml\": \"

Updated body content.

\",\n \"tags\": [\"updated\", \"news\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/blogs/{{blogId}}" + } + }, + { + "name": "Delete blog", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/blogs/{{blogId}}" + } + }, + { + "name": "List blog comments (admin)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/blogs/{{blogId}}/comments" + } + } + ] + }, + { + "name": "Business Portfolios", + "item": [ + { + "name": "List portfolios", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('portfolioSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/portfolios?page=1&pageSize=12", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "portfolios"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "12" }, + { "key": "status", "value": "", "disabled": true }, + { "key": "categoryId", "value": "", "disabled": true }, + { "key": "title", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create portfolio", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.portfolio?.id) pm.collectionVariables.set('portfolioId', json.portfolio.id);", + " if (json.portfolio?.slug) pm.collectionVariables.set('portfolioSlug', json.portfolio.slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Phone Launch Campaign\",\n \"abstract\": \"Product photos and video for Meshkee X Phone launch.\",\n \"mainTextHtml\": \"

Overview

Shot in studio with 3 lighting setups.

\\\"Studio\",\n \"categoryId\": \"{{portfolioCategoryId}}\",\n \"featuredMediaId\": \"1\",\n \"galleryMediaIds\": [\"2\", \"3\"],\n \"tags\": [\"photography\", \"product-launch\"],\n \"sortOrder\": 1,\n \"status\": \"published\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/portfolios" + } + }, + { + "name": "Get portfolio", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/portfolios/{{portfolioId}}" + } + }, + { + "name": "Update portfolio", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Updated Portfolio Title\",\n \"abstract\": \"Updated abstract.\",\n \"mainTextHtml\": \"

Updated body content.

\",\n \"tags\": [\"updated\", \"portfolio\"],\n \"galleryMediaIds\": [\"2\"]\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/portfolios/{{portfolioId}}" + } + }, + { + "name": "Delete portfolio", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/portfolios/{{portfolioId}}" + } + }, + { + "name": "List portfolio comments (admin)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/portfolios/{{portfolioId}}/comments" + } + } + ] + }, + { + "name": "Business Comments", + "item": [ + { + "name": "List comments (all)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/comments?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "comments"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "List pending comments", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/comments?isApproved=false&page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "comments"], + "query": [ + { "key": "isApproved", "value": "false" }, + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "List comments for product", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/comments?entityType=product&entityId={{productId}}", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "comments"], + "query": [ + { "key": "entityType", "value": "product" }, + { "key": "entityId", "value": "{{productId}}" } + ] + } + } + }, + { + "name": "List comments for blog", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/comments?entityType=blog&entityId={{blogId}}", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "comments"], + "query": [ + { "key": "entityType", "value": "blog" }, + { "key": "entityId", "value": "{{blogId}}" } + ] + } + } + }, + { + "name": "List comments for portfolio", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/comments?entityType=portfolio&entityId={{portfolioId}}", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "comments"], + "query": [ + { "key": "entityType", "value": "portfolio" }, + { "key": "entityId", "value": "{{portfolioId}}" } + ] + } + } + }, + { + "name": "Approve comment", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isApproved\": true\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/comments/{{commentId}}" + } + }, + { + "name": "Reject comment (unapprove)", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isApproved\": false\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/comments/{{commentId}}" + } + }, + { + "name": "Delete comment", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/comments/{{commentId}}" + } + } + ] + }, + { + "name": "Business Expert Reviews", + "item": [ + { + "name": "List expert reviews (all)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "expert-reviews"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "List pending expert reviews", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews?isApproved=false&page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "expert-reviews"], + "query": [ + { "key": "isApproved", "value": "false" }, + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "List expert reviews for product", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews?productId={{productId}}", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "expert-reviews"], + "query": [{ "key": "productId", "value": "{{productId}}" }] + } + } + }, + { + "name": "Approve expert review", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isApproved\": true\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews/{{expertReviewId}}" + } + }, + { + "name": "Reject expert review (unapprove)", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"isApproved\": false\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews/{{expertReviewId}}" + } + }, + { + "name": "Delete expert review", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/expert-reviews/{{expertReviewId}}" + } + } + ] + }, + { + "name": "Business Dashboard Settings", + "item": [ + { + "name": "Get dashboard settings", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/settings" + } + }, + { + "name": "Enable auto-approve for comments", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"dashboard\": {\n \"comments\": {\n \"autoApprove\": true\n }\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/settings" + } + }, + { + "name": "Enable auto-approve for expert reviews", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"dashboard\": {\n \"expertReviews\": {\n \"autoApprove\": true\n }\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/settings" + } + }, + { + "name": "Update all dashboard settings", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"dashboard\": {\n \"comments\": {\n \"autoApprove\": true\n },\n \"expertReviews\": {\n \"autoApprove\": false\n }\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/settings" + } + } + ] + }, + { + "name": "Website - Homepage", + "item": [ + { + "name": "List category groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/category-groups" + } + }, + { + "name": "List brand groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/brand-groups" + } + }, + { + "name": "List sliders (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" + } + }, + { + "name": "List store specials (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-specials" + } + } + ] + }, + { + "name": "Website - Cart", + "item": [ + { + "name": "Get cart", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Add variant to cart", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " const items = json.cart?.items || [];", + " if (items.length) pm.collectionVariables.set('cartItemId', items[items.length - 1].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"storeItemVariantId\": \"{{storeItemVariantId}}\",\n \"quantity\": 1\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items" + } + }, + { + "name": "Update cart item quantity", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 2\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Remove cart item", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Clear cart", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Checkout cart", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"shippingAddress\": {\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n },\n \"customerNotes\": \"Please call before delivery\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + } + ] + }, + { + "name": "Website - Orders", + "item": [ + { + "name": "List my orders", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/orders?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "orders"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Get my order", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + } + ] + }, + { + "name": "Business Orders", + "item": [ + { + "name": "List orders (admin)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/orders?page=1&pageSize=20", + "host": ["{{baseUrl}}"], + "path": ["businesses", "{{businessId}}", "orders"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "pageSize", "value": "20" } + ] + } + } + }, + { + "name": "Get order (admin)", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + }, + { + "name": "Create order (admin)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"customerUserId\": \"5\",\n \"items\": [\n { \"storeItemVariantId\": \"{{storeItemVariantId}}\", \"quantity\": 1 }\n ],\n \"shippingAddress\": {\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\"\n },\n \"status\": \"confirmed\",\n \"adminNotes\": \"Phone order\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/orders" + } + }, + { + "name": "Update order status (admin)", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"processing\",\n \"adminNotes\": \"Packed and ready to ship\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + } + ] + }, + { + "name": "Business Team", + "item": [ + { + "name": "List team members", + "request": { + "method": "GET", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/team" + } + }, + { + "name": "Invite team member", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.member?.id) pm.collectionVariables.set('memberId', json.member.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989128888888\",\n \"password\": \"password123\",\n \"firstName\": \"Team\",\n \"lastName\": \"Editor\",\n \"roleSlug\": \"editor\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/team" + } + }, + { + "name": "Update team member role", + "request": { + "method": "PATCH", + "header": [ + { "key": "Authorization", "value": "Bearer {{accessToken}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"roleSlug\": \"viewer\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/team/{{memberId}}" + } + }, + { + "name": "Remove team member", + "request": { + "method": "DELETE", + "header": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }], + "url": "{{baseUrl}}/businesses/{{businessId}}/team/{{memberId}}" + } + } + ] + } + ] +} diff --git a/postman/Meshkee-Website-API.postman_collection.json b/postman/Meshkee-Website-API.postman_collection.json new file mode 100644 index 0000000..2801626 --- /dev/null +++ b/postman/Meshkee-Website-API.postman_collection.json @@ -0,0 +1,1667 @@ +{ + "info": { + "name": "Meshkee Website API", + "description": "Customer-facing APIs for Meshkee business websites.\n\n**Quick start**\n1. Set `domain` (e.g. shop-a.local) and `baseUrl`\n2. Run **Resolve tenant** — saves `businessId`\n3. Run **Login - Customer** or **Register** — saves tokens\n4. Public content: `/tenants/{domain}/...` (no auth)\n5. Cart, orders, favorites: `/businesses/{businessId}/...` (Bearer token)\n\n**Dev seed customer:** +989124444444 / password\n**Dev domain:** shop-a.local (businessId 1)", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:3000/api/v1" + }, + { + "key": "domain", + "value": "shop-a.local" + }, + { + "key": "businessId", + "value": "1" + }, + { + "key": "accessToken", + "value": "" + }, + { + "key": "refreshToken", + "value": "" + }, + { + "key": "productId", + "value": "1" + }, + { + "key": "productSlug", + "value": "meshkee-x-phone" + }, + { + "key": "blogId", + "value": "1" + }, + { + "key": "blogSlug", + "value": "how-to-choose-phone" + }, + { + "key": "blogCategoryId", + "value": "5" + }, + { + "key": "portfolioId", + "value": "1" + }, + { + "key": "portfolioSlug", + "value": "phone-launch-campaign" + }, + { + "key": "portfolioCategoryId", + "value": "7" + }, + { + "key": "storeItemVariantId", + "value": "" + }, + { + "key": "cartItemId", + "value": "" + }, + { + "key": "orderId", + "value": "" + }, + { + "key": "commentId", + "value": "" + }, + { + "key": "expertReviewId", + "value": "" + }, + { + "key": "addressId", + "value": "" + }, + { + "key": "countryId", + "value": "" + }, + { + "key": "provinceId", + "value": "" + }, + { + "key": "cityId", + "value": "" + }, + { + "key": "categoryId", + "value": "2" + }, + { + "key": "brandId", + "value": "1" + } + ], + "item": [ + { + "name": "Tenant", + "item": [ + { + "name": "Resolve tenant by domain", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.id) pm.collectionVariables.set(\"businessId\", String(json.id));", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}" + } + } + ] + }, + { + "name": "Auth", + "item": [ + { + "name": "Register (customer on website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.accessToken) pm.collectionVariables.set('accessToken', json.accessToken);", + " if (json.refreshToken) pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989126666666\",\n \"password\": \"password123\",\n \"firstName\": \"New\",\n \"lastName\": \"Customer\",\n \"email\": \"new@example.com\",\n \"domain\": \"{{domain}}\"\n}" + }, + "url": "{{baseUrl}}/auth/register" + } + }, + { + "name": "Login - Customer", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"password\": \"password\"\n}" + }, + "url": "{{baseUrl}}/auth/login" + } + }, + { + "name": "Me (current user)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/me" + } + }, + { + "name": "Refresh token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " pm.collectionVariables.set('accessToken', json.accessToken);", + " pm.collectionVariables.set('refreshToken', json.refreshToken);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"refreshToken\": \"{{refreshToken}}\"\n}" + }, + "url": "{{baseUrl}}/auth/refresh" + } + }, + { + "name": "Send OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\"\n}" + }, + "url": "{{baseUrl}}/auth/send-otp" + } + }, + { + "name": "Verify OTP (SMS disabled)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"cellNumber\": \"+989124444444\",\n \"code\": \"123456\"\n}" + }, + "url": "{{baseUrl}}/auth/verify-otp" + } + }, + { + "name": "Update profile", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"firstName\": \"Ali\",\n \"lastName\": \"Customer\",\n \"email\": \"ali@example.com\"\n}" + }, + "url": "{{baseUrl}}/auth/profile" + } + }, + { + "name": "Change password", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"currentPassword\": \"password\",\n \"newPassword\": \"password123\"\n}" + }, + "url": "{{baseUrl}}/auth/change-password" + } + } + ] + }, + { + "name": "Addresses", + "item": [ + { + "name": "List my addresses", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set(\"addressId\", json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Create address", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.address?.id) pm.collectionVariables.set(\"addressId\", json.address.id);", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses" + } + }, + { + "name": "Update address", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"456 Updated Street\",\n \"postalCode\": \"1234567890\"\n}" + }, + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + }, + { + "name": "Delete address", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/auth/addresses/{{addressId}}" + } + } + ] + }, + { + "name": "Comments", + "item": [ + { + "name": "Submit comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"product\",\n \"entityId\": \"{{productId}}\",\n \"authorName\": \"Website Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Great product! Would recommend.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=product&entityId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "product" + }, + { + "key": "entityId", + "value": "{{productId}}" + } + ] + } + } + }, + { + "name": "Submit comment on blog (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"blog\",\n \"entityId\": \"{{blogId}}\",\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Great article, thanks for sharing!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for blog (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=blog&entityId={{blogId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + }, + { + "key": "entityId", + "value": "{{blogId}}" + } + ] + } + } + }, + { + "name": "Submit comment on portfolio (generic API)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"entityType\": \"portfolio\",\n \"entityId\": \"{{portfolioId}}\",\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Beautiful work!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/comments" + } + }, + { + "name": "List approved comments for portfolio (generic API)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/comments?entityType=portfolio&entityId={{portfolioId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "comments" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + }, + { + "key": "entityId", + "value": "{{portfolioId}}" + } + ] + } + } + } + ] + }, + { + "name": "Blogs", + "item": [ + { + "name": "List published blogs (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('blogId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('blogSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/blogs?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "blogs" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "type", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published blog by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogSlug}}" + } + }, + { + "name": "List blog comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + }, + { + "name": "Submit blog comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Blog Reader\",\n \"authorEmail\": \"reader@example.com\",\n \"text\": \"Very helpful post!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/blogs/{{blogId}}/comments" + } + } + ] + }, + { + "name": "Portfolios", + "item": [ + { + "name": "List published portfolios (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('portfolioId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('portfolioSlug', json.items[0].slug);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "portfolios" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "title", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published portfolio by slug (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" + } + }, + { + "name": "List portfolio comments (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + }, + { + "name": "Submit portfolio comment (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.comment?.id) pm.collectionVariables.set('commentId', json.comment.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"authorName\": \"Portfolio Visitor\",\n \"authorEmail\": \"visitor@example.com\",\n \"text\": \"Impressive project!\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioId}}/comments" + } + } + ] + }, + { + "name": "Expert Reviews", + "item": [ + { + "name": "Submit expert review (website)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.review?.id) pm.collectionVariables.set('expertReviewId', json.review.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\",\n \"authorName\": \"Expert Reviewer\",\n \"authorEmail\": \"expert@example.com\",\n \"rate\": 8,\n \"positivePoints\": [\"Build quality\", \"Great battery life\", \"Comfortable fit\"],\n \"negativePoints\": [\"Pricey\", \"Limited colors\"],\n \"text\": \"Solid product overall with a few trade-offs worth considering.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/expert-reviews" + } + }, + { + "name": "List approved expert reviews (website)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/expert-reviews?productId={{productId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "expert-reviews" + ], + "query": [ + { + "key": "productId", + "value": "{{productId}}" + } + ] + } + } + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "List product categories", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('categoryId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=product", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "product" + } + ] + } + } + }, + { + "name": "List blog categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=blog", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "blog" + } + ] + } + } + }, + { + "name": "List portfolio categories", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/categories?entityType=portfolio", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "categories" + ], + "query": [ + { + "key": "entityType", + "value": "portfolio" + } + ] + } + } + } + ] + }, + { + "name": "Products", + "item": [ + { + "name": "List published products", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('productId', json.items[0].id);", + " if (json.items?.[0]?.slug) pm.collectionVariables.set('productSlug', json.items[0].slug);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/products?page=1&pageSize=12", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "products" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "12" + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "categoryId", + "value": "{{categoryId}}", + "disabled": true + }, + { + "key": "brandId", + "value": "{{brandId}}", + "disabled": true + }, + { + "key": "tag", + "value": "", + "disabled": true + }, + { + "key": "inStore", + "value": "true", + "disabled": true + } + ] + } + } + }, + { + "name": "Get published product by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}" + } + }, + { + "name": "Get product variations by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/variations" + } + }, + { + "name": "Get product technical info by slug", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info" + } + } + ] + }, + { + "name": "Store Items", + "item": [ + { + "name": "List store item variants", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('storeItemVariantId', json.items[0].id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/tenants/{{domain}}/store-items?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "tenants", + "{{domain}}", + "store-items" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + }, + { + "key": "categoryId", + "value": "", + "disabled": true + }, + { + "key": "brandId", + "value": "", + "disabled": true + }, + { + "key": "productId", + "value": "{{productId}}", + "disabled": true + }, + { + "key": "name", + "value": "", + "disabled": true + }, + { + "key": "inStock", + "value": "true", + "disabled": true + }, + { + "key": "isFestival", + "value": "true", + "disabled": true + }, + { + "key": "minPrice", + "value": "", + "disabled": true + }, + { + "key": "maxPrice", + "value": "", + "disabled": true + } + ] + } + } + }, + { + "name": "Get store item by product ID", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const variant = json.storeItem?.variants?.[0];", + " if (variant?.id) pm.collectionVariables.set('storeItemVariantId', variant.id);", + "}" + ] + } + } + ], + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/by-product/{{productId}}" + } + }, + { + "name": "Get store item variant by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-items/{{storeItemVariantId}}" + } + } + ] + }, + { + "name": "Homepage", + "item": [ + { + "name": "List category groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/category-groups" + } + }, + { + "name": "List brand groups (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/brand-groups" + } + }, + { + "name": "List sliders (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/website/sliders" + } + }, + { + "name": "List store specials (website)", + "request": { + "method": "GET", + "url": "{{baseUrl}}/tenants/{{domain}}/store-specials" + } + } + ] + }, + { + "name": "Cart", + "item": [ + { + "name": "Get cart", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Add variant to cart", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " const items = json.cart?.items || [];", + " if (items.length) pm.collectionVariables.set('cartItemId', items[items.length - 1].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"storeItemVariantId\": \"{{storeItemVariantId}}\",\n \"quantity\": 1\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items" + } + }, + { + "name": "Update cart item quantity", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 2\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Remove cart item", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/items/{{cartItemId}}" + } + }, + { + "name": "Clear cart", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/cart" + } + }, + { + "name": "Checkout cart (cash payment)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"shippingAddress\": {\n \"province\": \"Tehran\",\n \"city\": \"Tehran\",\n \"address\": \"123 Example Street\",\n \"postalCode\": \"1234567890\",\n \"landline\": \"02112345678\"\n },\n \"customerNotes\": \"Please call before delivery\",\n \"payment\": {\n \"type\": \"cash\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + }, + { + "name": "Checkout cart (saved address)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200 || pm.response.code === 201) {", + " const json = pm.response.json();", + " if (json.order?.id) pm.collectionVariables.set('orderId', json.order.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"zarinpal\"\n }\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout" + } + } + ] + }, + { + "name": "Orders", + "item": [ + { + "name": "List my orders", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/orders?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "orders" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Get my order", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/orders/{{orderId}}" + } + } + ] + }, + { + "name": "Contact", + "item": [ + { + "name": "Submit contact form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Product inquiry\",\n \"name\": \"Website Visitor\",\n \"email\": \"visitor@example.com\",\n \"cellNumber\": \"+989121234567\",\n \"text\": \"I would like more information about your products.\"\n}" + }, + "url": "{{baseUrl}}/tenants/{{domain}}/contact-submissions" + } + } + ] + }, + { + "name": "Favorites", + "item": [ + { + "name": "List favorites", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/businesses/{{businessId}}/favorites?page=1&pageSize=20", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "businesses", + "{{businessId}}", + "favorites" + ], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "pageSize", + "value": "20" + } + ] + } + } + }, + { + "name": "Add favorite", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"productId\": \"{{productId}}\"\n}" + }, + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites" + } + }, + { + "name": "Remove favorite", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/businesses/{{businessId}}/favorites/{{productId}}" + } + } + ] + }, + { + "name": "Cities (address forms)", + "item": [ + { + "name": "List countries", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('countryId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=country", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "country" + } + ] + } + } + }, + { + "name": "List Iran provinces", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " if (json.items?.[0]?.id) pm.collectionVariables.set('provinceId', json.items[0].id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=province&parentSlug=iran", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "province" + }, + { + "key": "parentSlug", + "value": "iran" + } + ] + } + } + }, + { + "name": "List cities in Tehran province", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const json = pm.response.json();", + " const tehran = json.items?.find((item) => item.slug === 'tehran');", + " if (tehran?.id) pm.collectionVariables.set('cityId', tehran.id);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentSlug=tehran-province", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentSlug", + "value": "tehran-province" + } + ] + } + } + }, + { + "name": "List cities by province ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/cities?level=city&parentId={{provinceId}}", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "cities" + ], + "query": [ + { + "key": "level", + "value": "city" + }, + { + "key": "parentId", + "value": "{{provinceId}}" + } + ] + } + } + }, + { + "name": "Get city by ID", + "request": { + "method": "GET", + "url": "{{baseUrl}}/cities/{{cityId}}" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..c2be4da --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,1125 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id BigInt @id @default(autoincrement()) + cellNumber String @unique(map: "users_cell_number_unique") @map("cell_number") @db.VarChar(20) + passwordHash String @map("password_hash") @db.VarChar(255) + email String? @db.VarChar(255) + firstName String? @map("first_name") @db.VarChar(100) + lastName String? @map("last_name") @db.VarChar(100) + isActive Boolean @default(true) @map("is_active") + cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6) + lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + profile Json @default("{}") + addresses Address[] + blogs blogs[] + businessCustomers BusinessCustomer[] + businessUsersInvited BusinessUser[] @relation("BusinessInviter") + businessUsers BusinessUser[] @relation("BusinessMember") + carts Cart[] + commentsApproved Comment[] @relation("CommentApprover") + expertReviewsApproved ExpertReview[] @relation("ExpertReviewApprover") + favorites Favorite[] + mediaUploaded Media[] + ordersCreated Order[] @relation("OrderCreator") + orders Order[] @relation("OrderCustomer") + shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator") + shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer") + transactionsCreated Transaction[] @relation("TransactionCreator") + transactions Transaction[] @relation("TransactionCustomer") + userRoles UserRole[] + + @@index([cellNumber], map: "idx_users_cell_number") + @@map("users") +} + +model Business { + id BigInt @id @default(autoincrement()) + name String @db.VarChar(255) + slug String @unique(map: "businesses_slug_unique") @db.VarChar(100) + description String? + settings Json @default("{}") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + nameFa String? @map("name_fa") @db.VarChar(255) + about String? + vision String? + emails Json @default("[]") + phoneNumbers Json @default("[]") @map("phone_numbers") + socialMedia Json @default("{}") @map("social_media") + logoMediaId BigInt? @map("logo_media_id") + faviconMediaId BigInt? @map("favicon_media_id") + addresses Address[] + blogs blogs[] + brands Brand[] + categoryAssignments BusinessCategoryAssignment[] + businessCustomers BusinessCustomer[] + businessUsers BusinessUser[] + logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction) + faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction) + carts Cart[] + categories Category[] + contentCategoryAssignments CategoryAssignment[] + categoryTechnicalForms CategoryTechnicalForm[] + categoryVariations CategoryVariation[] + comments Comment[] + contactSubmissions ContactSubmission[] + domains Domain[] + expertReviews ExpertReview[] + favorites Favorite[] + media Media[] + mediaAttachments MediaAttachment[] + orders Order[] + portfolios portfolios[] + productTechnicalFieldValues ProductTechnicalFieldValue[] + products Product[] + shoppingCards ShoppingCard[] + storeItemVariants StoreItemVariant[] + storeItems StoreItem[] + storeSpecials StoreSpecial[] + transactions Transaction[] + website_brand_groups website_brand_groups[] + website_category_groups website_category_groups[] + website_sliders website_sliders[] + + @@map("businesses") +} + +model BusinessCategory { + id BigInt @id @default(autoincrement()) + parentId BigInt? @map("parent_id") + name String @db.VarChar(255) + slug String @unique(map: "business_categories_slug_unique") @db.VarChar(255) + description String? + icon String? @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + parent BusinessCategory? @relation("BusinessCategoryTree", fields: [parentId], references: [id], onUpdate: NoAction) + children BusinessCategory[] @relation("BusinessCategoryTree") + assignments BusinessCategoryAssignment[] + + @@index([parentId], map: "idx_business_categories_parent_id") + @@index([sortOrder], map: "idx_business_categories_sort_order") + @@map("business_categories") +} + +model BusinessCategoryAssignment { + businessId BigInt @map("business_id") + categoryId BigInt @map("category_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + category BusinessCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([businessId, categoryId]) + @@index([categoryId], map: "idx_business_category_assignments_category_id") + @@map("business_category_assignments") +} + +model Domain { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + host String @unique(map: "domains_host_unique") @db.VarChar(253) + isPrimary Boolean @default(false) @map("is_primary") + isVerified Boolean @default(false) @map("is_verified") + verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) + sslEnabled Boolean @default(false) @map("ssl_enabled") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) + isActive Boolean @default(true) @map("is_active") + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId], map: "idx_domains_business_id") + @@index([host], map: "idx_domains_host") + @@index([isActive], map: "idx_domains_is_active") + @@map("domains") +} + +model BusinessUser { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + isOwner Boolean @default(false) @map("is_owner") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + roleId BigInt? @map("role_id") + invitedBy BigInt? @map("invited_by") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + inviter User? @relation("BusinessInviter", fields: [invitedBy], references: [id], onUpdate: NoAction) + role Role? @relation(fields: [roleId], references: [id], onDelete: Restrict, onUpdate: NoAction) + user User @relation("BusinessMember", fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([businessId, userId], map: "business_users_business_user_unique") + @@index([userId], map: "idx_business_users_user_id") + @@index([businessId], map: "idx_business_users_business_id") + @@index([roleId], map: "idx_business_users_role_id") + @@map("business_users") +} + +model BusinessCustomer { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + isEnabled Boolean @default(true) @map("is_enabled") + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([businessId, userId], map: "business_customers_business_user_unique") + @@index([userId], map: "idx_business_customers_user_id") + @@index([businessId], map: "idx_business_customers_business_id") + @@index([businessId, isEnabled], map: "idx_business_customers_is_enabled") + @@map("business_customers") +} + +model Role { + id BigInt @id @default(autoincrement()) + name String @db.VarChar(100) + slug String @unique(map: "roles_slug_unique") @db.VarChar(100) + description String? + isSystem Boolean @default(true) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + businessUsers BusinessUser[] + rolePermissions RolePermission[] + userRoles UserRole[] + + @@map("roles") +} + +model UserRole { + id BigInt @id @default(autoincrement()) + userId BigInt @map("user_id") + roleId BigInt @map("role_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([userId, roleId], map: "user_roles_user_role_unique") + @@index([userId], map: "idx_user_roles_user_id") + @@map("user_roles") +} + +model Permission { + id BigInt @id @default(autoincrement()) + name String @db.VarChar(100) + slug String @unique(map: "permissions_slug_unique") @db.VarChar(100) + groupName String @map("group_name") @db.VarChar(50) + description String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + rolePermissions RolePermission[] + + @@map("permissions") +} + +model RolePermission { + roleId BigInt @map("role_id") + permissionId BigInt @map("permission_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade, onUpdate: NoAction) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([roleId, permissionId]) + @@index([permissionId], map: "idx_role_permissions_permission_id") + @@map("role_permissions") +} + +model Media { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + uploadedBy BigInt? @map("uploaded_by") + mediaType MediaType @map("media_type") + storageDisk String @default("local") @map("storage_disk") @db.VarChar(50) + storagePath String @map("storage_path") + publicUrl String @map("public_url") + fileName String @map("file_name") @db.VarChar(255) + originalFileName String @map("original_file_name") @db.VarChar(255) + mimeType String @map("mime_type") @db.VarChar(100) + fileSizeBytes BigInt @map("file_size_bytes") + width Int? + height Int? + durationSeconds Decimal? @map("duration_seconds") @db.Decimal(10, 2) + altText String? @map("alt_text") @db.VarChar(255) + caption String? + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + blogs blogs[] + brandImages Brand[] + logoBusinesses Business[] @relation("BusinessLogo") + faviconBusinesses Business[] @relation("BusinessFavicon") + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction) + attachments MediaAttachment[] + portfolios portfolios[] + featuredProducts Product[] @relation("ProductFeaturedMedia") + website_slider_slides website_slider_slides[] + + @@index([businessId], map: "idx_media_business_id") + @@index([businessId, mediaType], map: "idx_media_business_type") + @@index([businessId, createdAt(sort: Desc)], map: "idx_media_created_at") + @@map("media") +} + +model MediaAttachment { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + mediaId BigInt @map("media_id") + entityType MediaEntityType @map("entity_type") + entityId BigInt @map("entity_id") + sortOrder Int @default(0) @map("sort_order") + isFeatured Boolean @default(false) @map("is_featured") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([mediaId, entityType, entityId], map: "media_attachments_unique") + @@index([businessId, entityType, entityId, sortOrder], map: "idx_media_attachments_entity") + @@index([mediaId], map: "idx_media_attachments_media_id") + @@map("media_attachments") +} + +model Category { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + entityType MediaEntityType @map("entity_type") + parentId BigInt? @map("parent_id") + name String @db.VarChar(255) + slug String @db.VarChar(255) + description String? + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + nameFa String? @map("name_fa") @db.VarChar(255) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction) + children Category[] @relation("CategoryTree") + assignments CategoryAssignment[] + technicalForm CategoryTechnicalForm? + variations CategoryVariation[] + website_category_group_items website_category_group_items[] + + @@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique") + @@index([businessId, entityType, sortOrder], map: "idx_categories_business_entity") + @@index([parentId], map: "idx_categories_parent_id") + @@map("categories") +} + +model CategoryVariation { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + categoryId BigInt @map("category_id") + name String @db.VarChar(255) + variationType VariationType @map("variation_type") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + options CategoryVariationOption[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction) + productVariationValues ProductVariationValue[] + storeItemVariantSelections StoreItemVariantSelection[] + + @@index([categoryId], map: "idx_category_variations_category_id") + @@index([businessId], map: "idx_category_variations_business_id") + @@map("category_variations") +} + +model CategoryVariationOption { + id BigInt @id @default(autoincrement()) + variationId BigInt @map("variation_id") + label String @db.VarChar(255) + value String @db.VarChar(255) + colorHex String? @map("color_hex") @db.VarChar(7) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + variation CategoryVariation @relation(fields: [variationId], references: [id], onDelete: Cascade, onUpdate: NoAction) + productVariationValues ProductVariationValue[] + storeItemVariantSelections StoreItemVariantSelection[] + + @@unique([variationId, value], map: "category_variation_options_unique_value") + @@index([variationId], map: "idx_category_variation_options_variation_id") + @@map("category_variation_options") +} + +model Brand { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + nameEn String @map("name_en") @db.VarChar(255) + nameFa String? @map("name_fa") @db.VarChar(255) + imageMediaId BigInt? @map("image_media_id") + about String? + slug String @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + sort_order Int @default(0) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction) + products Product[] + website_brand_group_items website_brand_group_items[] + + @@unique([businessId, slug], map: "brands_business_slug_unique") + @@index([businessId], map: "idx_brands_business_id") + @@index([imageMediaId], map: "idx_brands_image_media_id") + @@index([businessId, sort_order], map: "idx_brands_business_sort_order") + @@map("brands") +} + +model Product { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + title String @db.VarChar(255) + slug String @db.VarChar(255) + description String? + content Json @default("{}") + price Decimal? @db.Decimal(12, 2) + compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2) + sku String? @db.VarChar(100) + stockQuantity Int? @map("stock_quantity") + status ContentStatus @default(draft) + featuredMediaId BigInt? @map("featured_media_id") + sortOrder Int @default(0) @map("sort_order") + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + brandId BigInt? @map("brand_id") + expertReviews ExpertReview[] + favorites Favorite[] + orderItems OrderItem[] + technicalFieldValues ProductTechnicalFieldValue[] + variationValues ProductVariationValue[] + brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction) + shoppingCardItems ShoppingCardItem[] + storeItem StoreItem[] + + @@unique([businessId, slug], map: "products_business_slug_unique") + @@index([businessId], map: "idx_products_business_id") + @@index([brandId], map: "idx_products_brand_id") + @@index([businessId, status], map: "idx_products_business_status") + @@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published") + @@map("products") +} + +model CategoryAssignment { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + categoryId BigInt @map("category_id") + entityType MediaEntityType @map("entity_type") + entityId BigInt @map("entity_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([categoryId, entityType, entityId], map: "category_assignments_unique") + @@index([businessId, entityType, entityId], map: "idx_category_assignments_entity") + @@index([categoryId], map: "idx_category_assignments_category_id") + @@map("category_assignments") +} + +model ProductVariationValue { + productId BigInt @map("product_id") + variationId BigInt @map("variation_id") + optionId BigInt @map("option_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + option CategoryVariationOption @relation(fields: [optionId], references: [id], onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: NoAction) + variation CategoryVariation @relation(fields: [variationId], references: [id], onUpdate: NoAction) + + @@id([productId, optionId]) + @@index([variationId], map: "idx_product_variation_values_variation_id") + @@map("product_variation_values") +} + +model CategoryTechnicalForm { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + categoryId BigInt @unique(map: "category_technical_forms_category_unique") @map("category_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + fields CategoryTechnicalFormField[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId], map: "idx_category_technical_forms_business_id") + @@map("category_technical_forms") +} + +model CategoryTechnicalFormField { + id BigInt @id @default(autoincrement()) + formId BigInt @map("form_id") + label String @db.VarChar(255) + fieldKey String @map("field_key") @db.VarChar(255) + fieldType TechnicalFieldType @map("field_type") + isRequired Boolean @default(false) @map("is_required") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + options CategoryTechnicalFormFieldOption[] + form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction) + values ProductTechnicalFieldValue[] + + @@unique([formId, fieldKey], map: "category_technical_form_fields_unique_key") + @@index([formId], map: "idx_category_technical_form_fields_form_id") + @@map("category_technical_form_fields") +} + +model CategoryTechnicalFormFieldOption { + id BigInt @id @default(autoincrement()) + fieldId BigInt @map("field_id") + label String @db.VarChar(255) + value String @db.VarChar(255) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction) + multiSelectValues ProductTechnicalFieldValueOption[] + selectedValues ProductTechnicalFieldValue[] + + @@unique([fieldId, value], map: "category_technical_form_field_options_unique_value") + @@index([fieldId], map: "idx_category_technical_form_field_options_field_id") + @@map("category_technical_form_field_options") +} + +model ProductTechnicalFieldValue { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + productId BigInt @map("product_id") + fieldId BigInt @map("field_id") + textValue String? @map("text_value") + optionId BigInt? @map("option_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + selectedOptions ProductTechnicalFieldValueOption[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction) + option CategoryTechnicalFormFieldOption? @relation(fields: [optionId], references: [id], onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([productId, fieldId], map: "product_technical_field_values_unique") + @@index([productId], map: "idx_product_technical_field_values_product_id") + @@index([businessId], map: "idx_product_technical_field_values_business_id") + @@map("product_technical_field_values") +} + +model ProductTechnicalFieldValueOption { + fieldValueId BigInt @map("field_value_id") + optionId BigInt @map("option_id") + fieldValue ProductTechnicalFieldValue @relation(fields: [fieldValueId], references: [id], onDelete: Cascade, onUpdate: NoAction) + option CategoryTechnicalFormFieldOption @relation(fields: [optionId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([fieldValueId, optionId]) + @@index([optionId], map: "idx_product_technical_field_value_options_option_id") + @@map("product_technical_field_value_options") +} + +model Comment { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + entityType MediaEntityType @map("entity_type") + entityId BigInt @map("entity_id") + authorName String @map("author_name") @db.VarChar(255) + authorEmail String? @map("author_email") @db.VarChar(255) + text String + isApproved Boolean @default(false) @map("is_approved") + approvedAt DateTime? @map("approved_at") @db.Timestamptz(6) + approvedBy BigInt? @map("approved_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + approver User? @relation("CommentApprover", fields: [approvedBy], references: [id], onUpdate: NoAction) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId, isApproved, createdAt(sort: Desc)], map: "idx_comments_business_approval") + @@index([businessId, entityType, entityId, isApproved, createdAt(sort: Desc)], map: "idx_comments_entity") + @@map("comments") +} + +model ContactSubmission { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + title String @db.VarChar(255) + name String @db.VarChar(255) + email String? @db.VarChar(255) + cellNumber String? @map("cell_number") @db.VarChar(20) + text String + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId, createdAt(sort: Desc)], map: "idx_contact_submissions_business_created") + @@map("contact_submissions") +} + +model ExpertReview { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + productId BigInt @map("product_id") + authorName String @map("author_name") @db.VarChar(255) + authorEmail String? @map("author_email") @db.VarChar(255) + rate Int @db.SmallInt + positivePoints String[] @default([]) @map("positive_points") + negativePoints String[] @default([]) @map("negative_points") + text String + isApproved Boolean @default(false) @map("is_approved") + approvedAt DateTime? @map("approved_at") @db.Timestamptz(6) + approvedBy BigInt? @map("approved_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + approver User? @relation("ExpertReviewApprover", fields: [approvedBy], references: [id], onUpdate: NoAction) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId, isApproved, createdAt(sort: Desc)], map: "idx_expert_reviews_business_approval") + @@index([businessId, productId, isApproved, createdAt(sort: Desc)], map: "idx_expert_reviews_product") + @@map("expert_reviews") +} + +model blogs { + id BigInt @id @default(autoincrement()) + business_id BigInt + author_id BigInt? + title String @db.VarChar(255) + slug String @db.VarChar(255) + excerpt String? + content Json @default("{}") + status ContentStatus @default(draft) + featured_media_id BigInt? + published_at DateTime? @db.Timestamptz(6) + metadata Json @default("{}") + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + post_type BlogPostType @default(blog) + users User? @relation(fields: [author_id], references: [id], onUpdate: NoAction) + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction) + + @@unique([business_id, slug], map: "blogs_business_slug_unique") + @@index([business_id], map: "idx_blogs_business_id") + @@index([business_id, post_type], map: "idx_blogs_business_post_type") + @@index([business_id, published_at(sort: Desc)], map: "idx_blogs_business_published") + @@index([business_id, status], map: "idx_blogs_business_status") +} + +model portfolios { + id BigInt @id @default(autoincrement()) + business_id BigInt + title String @db.VarChar(255) + slug String @db.VarChar(255) + description String? + content Json @default("{}") + client_name String? @db.VarChar(255) + project_url String? + status ContentStatus @default(draft) + featured_media_id BigInt? + sort_order Int @default(0) + published_at DateTime? @db.Timestamptz(6) + metadata Json @default("{}") + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction) + + @@unique([business_id, slug], map: "portfolios_business_slug_unique") + @@index([business_id], map: "idx_portfolios_business_id") + @@index([business_id, published_at(sort: Desc)], map: "idx_portfolios_business_published") + @@index([business_id, status], map: "idx_portfolios_business_status") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Address { + id BigInt @id @default(autoincrement()) + userId BigInt? @map("user_id") + businessId BigInt? @map("business_id") + label String? @db.VarChar(100) + province String @db.VarChar(100) + city String @db.VarChar(100) + address String + postalCode String? @map("postal_code") @db.VarChar(20) + landline String? @db.VarChar(30) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + orders Order[] + + @@map("addresses") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model City { + id BigInt @id @default(autoincrement()) + parentId BigInt? @map("parent_id") + level CityLevel + nameFa String @map("name_fa") @db.VarChar(255) + nameEn String @map("name_en") @db.VarChar(255) + landlineCode String? @map("landline_code") @db.VarChar(10) + slug String @unique(map: "cities_slug_unique") @db.VarChar(100) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction) + children City[] @relation("CityTree") + + @@index([level], map: "idx_cities_level") + @@index([level, parentId, sortOrder], map: "idx_cities_level_parent") + @@index([parentId], map: "idx_cities_parent_id") + @@map("cities") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model CartItem { + id BigInt @id @default(autoincrement()) + cartId BigInt @map("cart_id") + storeItemVariantId BigInt @map("store_item_variant_id") + quantity Int @default(1) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade, onUpdate: NoAction) + storeItemVariant StoreItemVariant @relation(fields: [storeItemVariantId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([cartId, storeItemVariantId], map: "cart_items_cart_store_item_variant_unique") + @@index([cartId], map: "idx_cart_items_cart_id") + @@index([storeItemVariantId], map: "idx_cart_items_store_item_variant_id") + @@map("cart_items") +} + +model Cart { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + items CartItem[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([businessId, userId], map: "carts_business_user_unique") + @@index([businessId], map: "idx_carts_business_id") + @@index([userId], map: "idx_carts_user_id") + @@map("carts") +} + +model Favorite { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + productId BigInt @map("product_id") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: NoAction) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([businessId, userId, productId], map: "favorites_business_user_product_unique") + @@index([businessId, userId, createdAt(sort: Desc)], map: "idx_favorites_business_user_created") + @@index([productId], map: "idx_favorites_product_id") + @@map("favorites") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model OrderItem { + id BigInt @id @default(autoincrement()) + orderId BigInt @map("order_id") + storeItemVariantId BigInt? @map("store_item_variant_id") + productId BigInt @map("product_id") + productTitle String @map("product_title") @db.VarChar(255) + variantSku String? @map("variant_sku") @db.VarChar(100) + unitPrice Decimal @map("unit_price") @db.Decimal(12, 2) + compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2) + quantity Int + lineTotal Decimal @map("line_total") @db.Decimal(12, 2) + selectionsSnapshot Json @default("[]") @map("selections_snapshot") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade, onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onUpdate: NoAction) + storeItemVariant StoreItemVariant? @relation(fields: [storeItemVariantId], references: [id], onUpdate: NoAction) + + @@index([orderId], map: "idx_order_items_order_id") + @@index([storeItemVariantId], map: "idx_order_items_store_item_variant_id") + @@map("order_items") +} + +model ShoppingCardItem { + id BigInt @id @default(autoincrement()) + shoppingCardId BigInt @map("shopping_card_id") + storeItemVariantId BigInt? @map("store_item_variant_id") + productId BigInt @map("product_id") + productTitle String @map("product_title") @db.VarChar(255) + variantSku String? @map("variant_sku") @db.VarChar(100) + unitPrice Decimal @map("unit_price") @db.Decimal(12, 2) + compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2) + quantity Int + lineTotal Decimal @map("line_total") @db.Decimal(12, 2) + selectionsSnapshot Json @default("[]") @map("selections_snapshot") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + shoppingCard ShoppingCard @relation(fields: [shoppingCardId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "shopping_card_items_card_id_fkey") + product Product @relation(fields: [productId], references: [id], onUpdate: NoAction) + storeItemVariant StoreItemVariant? @relation(fields: [storeItemVariantId], references: [id], onUpdate: NoAction, map: "shopping_card_items_variant_id_fkey") + + @@index([shoppingCardId], map: "idx_shopping_card_items_card_id") + @@index([storeItemVariantId], map: "idx_shopping_card_items_variant_id") + @@map("shopping_card_items") +} + +model ShoppingCard { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + subtotal Decimal @default(0) @db.Decimal(12, 2) + total Decimal @default(0) @db.Decimal(12, 2) + createdBy BigInt? @map("created_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + items ShoppingCardItem[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + creator User? @relation("ShoppingCardCreator", fields: [createdBy], references: [id], onUpdate: NoAction) + customer User @relation("ShoppingCardCustomer", fields: [userId], references: [id], onUpdate: NoAction) + + @@index([businessId, createdAt(sort: Desc)], map: "idx_shopping_cards_business_created") + @@index([businessId, userId, createdAt(sort: Desc)], map: "idx_shopping_cards_business_user") + @@map("shopping_cards") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Order { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + userId BigInt @map("user_id") + orderNumber String @map("order_number") @db.VarChar(30) + status OrderStatus @default(pending) + source OrderSource @default(website) + subtotal Decimal @default(0) @db.Decimal(12, 2) + shippingTotal Decimal @default(0) @map("shipping_total") @db.Decimal(12, 2) + discountTotal Decimal @default(0) @map("discount_total") @db.Decimal(12, 2) + total Decimal @default(0) @db.Decimal(12, 2) + shippingAddress Json @default("{}") @map("shipping_address") + addressId BigInt? @map("address_id") + customerNotes String? @map("customer_notes") + adminNotes String? @map("admin_notes") + createdBy BigInt? @map("created_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + processStepId String @default("processing") @map("process_step_id") @db.VarChar(64) + items OrderItem[] + address Address? @relation(fields: [addressId], references: [id], onUpdate: NoAction) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + creator User? @relation("OrderCreator", fields: [createdBy], references: [id], onUpdate: NoAction) + customer User @relation("OrderCustomer", fields: [userId], references: [id], onUpdate: NoAction) + transactions Transaction[] + + @@unique([businessId, orderNumber], map: "orders_business_order_number_unique") + @@index([businessId, createdAt(sort: Desc)], map: "idx_orders_business_created") + @@index([businessId, status, createdAt(sort: Desc)], map: "idx_orders_business_status") + @@index([businessId, processStepId], map: "idx_orders_business_process_step") + @@index([businessId, userId, createdAt(sort: Desc)], map: "idx_orders_business_user") + @@map("orders") +} + +model StoreItemVariantSelection { + variantId BigInt @map("variant_id") + variationId BigInt @map("variation_id") + optionId BigInt @map("option_id") + option CategoryVariationOption @relation(fields: [optionId], references: [id], onUpdate: NoAction) + variant StoreItemVariant @relation(fields: [variantId], references: [id], onDelete: Cascade, onUpdate: NoAction) + variation CategoryVariation @relation(fields: [variationId], references: [id], onUpdate: NoAction) + + @@id([variantId, variationId]) + @@unique([variantId, optionId], map: "store_item_variant_selections_unique_option") + @@index([optionId], map: "idx_store_item_variant_selections_option_id") + @@map("store_item_variant_selections") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model StoreItemVariant { + id BigInt @id @default(autoincrement()) + storeItemId BigInt @map("store_item_id") + businessId BigInt @map("business_id") + sku String? @db.VarChar(100) + price Decimal? @db.Decimal(12, 2) + compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2) + stockQuantity Int? @map("stock_quantity") + isActive Boolean @default(true) @map("is_active") + isFestival Boolean @default(false) @map("is_festival") + rewardPoints Int? @map("reward_points") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + cartItems CartItem[] + orderItems OrderItem[] + shoppingCardItems ShoppingCardItem[] + selections StoreItemVariantSelection[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + storeItem StoreItem @relation(fields: [storeItemId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId], map: "idx_store_item_variants_business_id") + @@index([storeItemId], map: "idx_store_item_variants_store_item_id") + @@map("store_item_variants") +} + +model StoreItem { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + productId BigInt @map("product_id") + isActive Boolean @default(true) @map("is_active") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + variants StoreItemVariant[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: NoAction) + specialItems StoreSpecialItem[] + + @@unique([businessId, productId], map: "store_items_business_product_unique") + @@index([businessId], map: "idx_store_items_business_id") + @@index([productId], map: "idx_store_items_product_id") + @@map("store_items") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Transaction { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + orderId BigInt? @map("order_id") + userId BigInt @map("user_id") + type TransactionType + status TransactionStatus @default(pending) + amount Decimal @db.Decimal(12, 2) + posType String? @map("pos_type") @db.VarChar(100) + gatewayType String? @map("gateway_type") @db.VarChar(100) + transferAccount String? @map("transfer_account") @db.VarChar(255) + transferRefNumber String? @map("transfer_ref_number") @db.VarChar(100) + notes String? + createdBy BigInt? @map("created_by") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + creator User? @relation("TransactionCreator", fields: [createdBy], references: [id], onUpdate: NoAction) + order Order? @relation(fields: [orderId], references: [id], onUpdate: NoAction) + customer User @relation("TransactionCustomer", fields: [userId], references: [id], onUpdate: NoAction) + + @@index([businessId, createdAt(sort: Desc)], map: "idx_transactions_business_created") + @@index([orderId], map: "idx_transactions_order_id") + @@index([businessId, userId, createdAt(sort: Desc)], map: "idx_transactions_business_user") + @@map("transactions") +} + +model StoreSpecialItem { + id BigInt @id @default(autoincrement()) + specialId BigInt @map("special_id") + storeItemId BigInt @map("store_item_id") + sortOrder Int @default(0) @map("sort_order") + special StoreSpecial @relation(fields: [specialId], references: [id], onDelete: Cascade, onUpdate: NoAction) + storeItem StoreItem @relation(fields: [storeItemId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([specialId, storeItemId], map: "store_special_items_unique") + @@index([specialId], map: "idx_store_special_items_special_id") + @@index([storeItemId], map: "idx_store_special_items_store_item_id") + @@map("store_special_items") +} + +model StoreSpecial { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + title String @db.VarChar(255) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + items StoreSpecialItem[] + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([businessId], map: "idx_store_specials_business_id") + @@map("store_specials") +} + +model website_brand_group_items { + id BigInt @id @default(autoincrement()) + group_id BigInt + brand_id BigInt + sort_order Int @default(0) + brands Brand @relation(fields: [brand_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + website_brand_groups website_brand_groups @relation(fields: [group_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([group_id, brand_id], map: "website_brand_group_items_unique") + @@index([brand_id], map: "idx_website_brand_group_items_brand_id") + @@index([group_id], map: "idx_website_brand_group_items_group_id") +} + +model website_brand_groups { + id BigInt @id @default(autoincrement()) + business_id BigInt + title String @db.VarChar(255) + sort_order Int @default(0) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + website_brand_group_items website_brand_group_items[] + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([business_id], map: "idx_website_brand_groups_business_id") +} + +model website_category_group_items { + id BigInt @id @default(autoincrement()) + group_id BigInt + category_id BigInt + sort_order Int @default(0) + categories Category @relation(fields: [category_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + website_category_groups website_category_groups @relation(fields: [group_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([group_id, category_id], map: "website_category_group_items_unique") + @@index([category_id], map: "idx_website_category_group_items_category_id") + @@index([group_id], map: "idx_website_category_group_items_group_id") +} + +model website_category_groups { + id BigInt @id @default(autoincrement()) + business_id BigInt + title String @db.VarChar(255) + sort_order Int @default(0) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + website_category_group_items website_category_group_items[] + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([business_id], map: "idx_website_category_groups_business_id") +} + +model website_slider_slides { + id BigInt @id @default(autoincrement()) + slider_id BigInt + image_media_id BigInt + title String? @db.VarChar(255) + link_url String? @db.VarChar(2048) + sort_order Int @default(0) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + media Media @relation(fields: [image_media_id], references: [id], onUpdate: NoAction) + website_sliders website_sliders @relation(fields: [slider_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([image_media_id], map: "idx_website_slider_slides_image_media_id") + @@index([slider_id], map: "idx_website_slider_slides_slider_id") +} + +model website_sliders { + id BigInt @id @default(autoincrement()) + business_id BigInt + title String @db.VarChar(255) + sort_order Int @default(0) + is_active Boolean @default(true) + created_at DateTime @default(now()) @db.Timestamptz(6) + updated_at DateTime @default(now()) @db.Timestamptz(6) + website_slider_slides website_slider_slides[] + businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([business_id], map: "idx_website_sliders_business_id") +} + +enum MediaType { + image + video + + @@map("media_type") +} + +enum MediaEntityType { + product + blog + portfolio + + @@map("media_entity_type") +} + +enum VariationType { + color + size + custom + + @@map("variation_type") +} + +enum ContentStatus { + draft + published + archived + + @@map("content_status") +} + +enum BlogPostType { + news + article + blog + + @@map("blog_post_type") +} + +enum TechnicalFieldType { + text + textarea + select + multi_select + + @@map("technical_field_type") +} + +enum CityLevel { + country + province + city + + @@map("city_level") +} + +enum OrderSource { + website + admin + + @@map("order_source") +} + +enum OrderStatus { + pending + confirmed + processing + shipped + delivered + cancelled + + @@map("order_status") +} + +enum TransactionType { + pos + cash + transfer + e_payment_gate + + @@map("transaction_type") +} + +enum TransactionStatus { + pending + completed + failed + refunded + + @@map("transaction_status") +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..c0caca0 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,67 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from './prisma/prisma.module'; +import { RedisModule } from './redis/redis.module'; +import { AuthModule } from './auth/auth.module'; +import { BusinessTeamModule } from './business-team/business-team.module'; +import { BusinessAdminModule } from './business-admin/business-admin.module'; +import { UsersModule } from './users/users.module'; +import { RolesModule } from './roles/roles.module'; +import { TenantModule } from './tenant/tenant.module'; +import { StorageModule } from './storage/storage.module'; +import { MediaModule } from './media/media.module'; +import { DomainAdminModule } from './domain-admin/domain-admin.module'; +import { CategoriesModule } from './categories/categories.module'; +import { ProductsModule } from './products/products.module'; +import { BlogsModule } from './blogs/blogs.module'; +import { PortfoliosModule } from './portfolios/portfolios.module'; +import { CommentsModule } from './comments/comments.module'; +import { CitiesModule } from './cities/cities.module'; +import { ExpertReviewsModule } from './expert-reviews/expert-reviews.module'; +import { BusinessSettingsModule } from './business-settings/business-settings.module'; +import { BusinessProfileModule } from './business-profile/business-profile.module'; +import { StoreModule } from './store/store.module'; +import { CartModule } from './cart/cart.module'; +import { OrdersModule } from './orders/orders.module'; +import { CustomersModule } from './customers/customers.module'; +import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module'; +import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module'; +import { FavoritesModule } from './favorites/favorites.module'; +import { BrandsModule } from './brands/brands.module'; +import { WebsiteModule } from './website/website.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + PrismaModule, + RedisModule, + AuthModule, + BusinessTeamModule, + BusinessAdminModule, + UsersModule, + RolesModule, + TenantModule, + StorageModule, + MediaModule, + DomainAdminModule, + CategoriesModule, + ProductsModule, + BlogsModule, + PortfoliosModule, + CommentsModule, + ExpertReviewsModule, + BusinessSettingsModule, + BusinessProfileModule, + CitiesModule, + StoreModule, + CartModule, + OrdersModule, + CustomersModule, + ShoppingCardsModule, + ContactSubmissionsModule, + FavoritesModule, + BrandsModule, + WebsiteModule, + ], +}) +export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..2e88bd7 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,108 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { LoginDto } from './dto/login.dto'; +import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { RegisterDto } from './dto/register.dto'; +import { SendOtpDto } from './dto/send-otp.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { UpsertUserAddressDto } from './dto/upsert-user-address.dto'; +import { VerifyOtpDto } from './dto/verify-otp.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { AuthUser } from './auth.types'; +import { UserAddressesService } from './user-addresses.service'; + +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly userAddresses: UserAddressesService, + ) {} + + @Post('register') + register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @Post('refresh') + refresh(@Body() dto: RefreshTokenDto) { + return this.authService.refresh(dto.refreshToken); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@CurrentUser() user: AuthUser) { + return this.authService.me(user); + } + + @Patch('profile') + @UseGuards(JwtAuthGuard) + updateProfile(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) { + return this.authService.updateProfile(user, dto); + } + + @Post('change-password') + @UseGuards(JwtAuthGuard) + changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) { + return this.authService.changePassword(user, dto); + } + + @Post('send-otp') + sendOtp(@Body() dto: SendOtpDto) { + return this.authService.sendOtp(dto.cellNumber); + } + + @Post('verify-otp') + verifyOtp(@Body() dto: VerifyOtpDto) { + return this.authService.verifyOtp(dto.cellNumber, dto.code); + } + + @Get('addresses') + @UseGuards(JwtAuthGuard) + listAddresses(@CurrentUser() user: AuthUser) { + return this.userAddresses.list(user); + } + + @Post('addresses') + @UseGuards(JwtAuthGuard) + createAddress( + @CurrentUser() user: AuthUser, + @Body() dto: UpsertUserAddressDto, + ) { + return this.userAddresses.create(user, dto); + } + + @Patch('addresses/:addressId') + @UseGuards(JwtAuthGuard) + updateAddress( + @CurrentUser() user: AuthUser, + @Param('addressId') addressId: string, + @Body() dto: UpsertUserAddressDto, + ) { + return this.userAddresses.update(user, addressId, dto); + } + + @Delete('addresses/:addressId') + @UseGuards(JwtAuthGuard) + removeAddress( + @CurrentUser() user: AuthUser, + @Param('addressId') addressId: string, + ) { + return this.userAddresses.remove(user, addressId); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..615a217 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { BusinessPermissionGuard } from './guards/business-permission.guard'; +import { PermissionsService } from './permissions.service'; +import { SmsService } from './sms.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { UserAddressesService } from './user-addresses.service'; + +@Module({ + imports: [ + PrismaModule, + TenantModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_ACCESS_SECRET'), + signOptions: { + expiresIn: config.get('JWT_ACCESS_EXPIRES_IN', '15m') as `${number}${'s' | 'm' | 'h' | 'd'}`, + }, + }), + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + UserAddressesService, + SmsService, + PermissionsService, + BusinessPermissionGuard, + JwtStrategy, + ], + exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..95fd33d --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,467 @@ +import { + BadRequestException, + ConflictException, + Injectable, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { RedisService } from '../redis/redis.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + AuthJwtPayload, + AuthUser, + DashboardType, + UserProfile, + resolvePrimaryRole, +} from './auth.types'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { LoginDto } from './dto/login.dto'; +import { RegisterDto } from './dto/register.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { PermissionsService } from './permissions.service'; +import { parseUserProfile } from './profile.util'; +import { SmsService } from './sms.service'; + +const OTP_TTL_SECONDS = 300; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly redis: RedisService, + private readonly sms: SmsService, + private readonly tenant: TenantService, + private readonly permissions: PermissionsService, + ) {} + + async register(dto: RegisterDto) { + const business = await this.tenant.resolveBusinessByDomain(dto.domain); + const passwordHash = await bcrypt.hash(dto.password, 10); + const smsEnabled = this.sms.isEnabled(); + + const customerRole = await this.prisma.role.findUnique({ + where: { slug: 'customer' }, + }); + + if (!customerRole) { + throw new Error('Customer role is missing. Run database migrations first.'); + } + + const existingUser = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + include: { + businessCustomers: { where: { businessId: business.id } }, + }, + }); + + if (existingUser?.businessCustomers.length) { + throw new ConflictException( + 'This cell number is already registered on this website', + ); + } + + const user = await this.prisma.$transaction(async (tx) => { + const account = + existingUser ?? + (await tx.user.create({ + data: { + cellNumber: dto.cellNumber, + passwordHash, + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + cellVerifiedAt: smsEnabled ? null : new Date(), + }, + })); + + if (existingUser) { + const passwordValid = await bcrypt.compare( + dto.password, + existingUser.passwordHash, + ); + if (!passwordValid) { + throw new ConflictException( + 'Cell number exists on another account. Use login or reset password.', + ); + } + } + + await tx.businessCustomer.create({ + data: { + businessId: business.id, + userId: account.id, + }, + }); + + const hasCustomerRole = await tx.userRole.findUnique({ + where: { + userId_roleId: { + userId: account.id, + roleId: customerRole.id, + }, + }, + }); + + if (!hasCustomerRole) { + await tx.userRole.create({ + data: { + userId: account.id, + roleId: customerRole.id, + }, + }); + } + + return account; + }); + + const authUser = await this.getAuthUser(user.id); + const tokens = await this.issueTokens(authUser); + + return { + message: smsEnabled + ? 'Registration successful. Please verify your cell number with OTP.' + : 'Registration successful. SMS verification is disabled — account auto-verified.', + smsEnabled, + user: this.serializeUser(authUser), + registeredBusiness: { + id: business.id, + name: business.name, + slug: business.slug, + }, + ...tokens, + }; + } + + async login(dto: LoginDto) { + const user = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('Invalid cell number or password'); + } + + const passwordValid = await bcrypt.compare(dto.password, user.passwordHash); + if (!passwordValid) { + throw new UnauthorizedException('Invalid cell number or password'); + } + + if (this.sms.isEnabled() && !user.cellVerifiedAt) { + throw new UnauthorizedException( + 'Cell number is not verified. Please complete OTP verification.', + ); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + const authUser = await this.getAuthUser(user.id); + const tokens = await this.issueTokens(authUser); + + return { + message: 'Login successful', + user: this.serializeUser(authUser), + ...tokens, + }; + } + + async refresh(refreshToken: string) { + let payload: AuthJwtPayload; + + try { + payload = await this.jwt.verifyAsync(refreshToken, { + secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), + }); + } catch { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } + + const authUser = await this.getAuthUser(BigInt(payload.sub)); + const tokens = await this.issueTokens(authUser); + + return { + message: 'Token refreshed', + user: this.serializeUser(authUser), + ...tokens, + }; + } + + async me(user: AuthUser) { + return { user: this.serializeUser(user) }; + } + + async updateProfile(user: AuthUser, dto: UpdateProfileDto) { + const currentProfile = user.profile; + const nextProfile: UserProfile = { + ...currentProfile, + about: dto.about ?? currentProfile.about, + city: dto.city ?? currentProfile.city, + address: dto.address ?? currentProfile.address, + landline: dto.landline ?? currentProfile.landline, + backupPhone: dto.backupPhone ?? currentProfile.backupPhone, + postalCode: dto.postalCode ?? currentProfile.postalCode, + instagram: dto.instagram ?? currentProfile.instagram, + telegramId: dto.telegramId ?? currentProfile.telegramId, + linkedin: dto.linkedin ?? currentProfile.linkedin, + }; + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + firstName: dto.firstName ?? user.firstName, + lastName: dto.lastName ?? user.lastName, + email: dto.email ?? user.email, + profile: nextProfile as object, + }, + }); + + const authUser = await this.getAuthUser(user.id); + + return { + message: 'Profile updated successfully', + user: this.serializeUser(authUser), + }; + } + + async changePassword(user: AuthUser, dto: ChangePasswordDto) { + const account = await this.prisma.user.findUnique({ + where: { id: user.id }, + }); + + if (!account) { + throw new UnauthorizedException('User not found'); + } + + const passwordValid = await bcrypt.compare( + dto.currentPassword, + account.passwordHash, + ); + + if (!passwordValid) { + throw new BadRequestException('Current password is incorrect'); + } + + if (dto.currentPassword === dto.newPassword) { + throw new BadRequestException( + 'New password must be different from the current password', + ); + } + + const passwordHash = await bcrypt.hash(dto.newPassword, 10); + + await this.prisma.user.update({ + where: { id: user.id }, + data: { passwordHash }, + }); + + return { message: 'Password changed successfully' }; + } + + async sendOtp(cellNumber: string) { + if (!this.sms.isEnabled()) { + return { + enabled: false, + message: + 'SMS verification is currently disabled. Register and login work without OTP.', + }; + } + + const user = await this.prisma.user.findUnique({ + where: { cellNumber }, + }); + + if (!user) { + throw new UnauthorizedException('Cell number is not registered'); + } + + const code = this.generateOtpCode(); + await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS); + + try { + await this.sms.sendVerificationCode(cellNumber, code); + } catch { + throw new ServiceUnavailableException( + 'SMS provider is not configured yet', + ); + } + + return { + enabled: true, + message: 'Verification code sent', + expiresInSeconds: OTP_TTL_SECONDS, + }; + } + + async verifyOtp(cellNumber: string, code: string) { + if (!this.sms.isEnabled()) { + const user = await this.prisma.user.findUnique({ + where: { cellNumber }, + }); + + if (!user) { + throw new UnauthorizedException('Cell number is not registered'); + } + + if (!user.cellVerifiedAt) { + await this.prisma.user.update({ + where: { id: user.id }, + data: { cellVerifiedAt: new Date() }, + }); + } + + return { + enabled: false, + verified: true, + message: 'SMS verification is disabled — cell number marked as verified.', + }; + } + + const storedCode = await this.redis.getOtp(cellNumber); + if (!storedCode || storedCode !== code) { + throw new UnauthorizedException('Invalid or expired verification code'); + } + + const user = await this.prisma.user.findUnique({ + where: { cellNumber }, + }); + + if (!user) { + throw new UnauthorizedException('Cell number is not registered'); + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { cellVerifiedAt: new Date() }, + }); + await this.redis.deleteOtp(cellNumber); + + return { + enabled: true, + verified: true, + message: 'Cell number verified successfully', + }; + } + + private async getAuthUser(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + userRoles: { include: { role: true } }, + businessUsers: { include: { business: true } }, + businessCustomers: { include: { business: true } }, + }, + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const roles = user.userRoles.map((ur) => ur.role.slug); + const businesses = await this.permissions.getBusinessMemberships(user.id); + const customerBusinesses = user.businessCustomers.map((bc) => ({ + id: bc.business.id, + name: bc.business.name, + slug: bc.business.slug, + })); + + return { + id: user.id, + cellNumber: user.cellNumber, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + cellVerifiedAt: user.cellVerifiedAt, + roles, + dashboard: this.resolveDashboard(roles, businesses.length), + profile: parseUserProfile(user.profile), + businesses, + customerBusinesses, + }; + } + + private resolveDashboard(roles: string[], businessCount: number): DashboardType { + if (roles.includes('super_admin')) { + return 'super_admin'; + } + if ( + roles.includes('business_owner') || + roles.includes('business_staff') || + roles.includes('owner') || + businessCount > 0 + ) { + return 'business'; + } + return 'customer'; + } + + private async issueTokens(user: AuthUser) { + const accessPayload: AuthJwtPayload = { + sub: user.id.toString(), + cellNumber: user.cellNumber, + roles: user.roles, + dashboard: user.dashboard, + type: 'access', + }; + + const refreshPayload: AuthJwtPayload = { + sub: user.id.toString(), + cellNumber: user.cellNumber, + roles: user.roles, + dashboard: user.dashboard, + type: 'refresh', + }; + + const accessExpiresIn = this.config.get('JWT_ACCESS_EXPIRES_IN', '15m'); + const refreshExpiresIn = this.config.get('JWT_REFRESH_EXPIRES_IN', '7d'); + + const [accessToken, refreshToken] = await Promise.all([ + this.jwt.signAsync(accessPayload, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`, + }), + this.jwt.signAsync(refreshPayload, { + secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), + expiresIn: refreshExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`, + }), + ]); + + return { accessToken, refreshToken }; + } + + private serializeUser(user: AuthUser) { + const primaryRole = resolvePrimaryRole(user.roles); + + return { + id: user.id, + cellNumber: user.cellNumber, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + cellVerifiedAt: user.cellVerifiedAt, + roles: user.roles, + dashboard: user.dashboard, + primaryRole: primaryRole.slug, + roleLabel: primaryRole.label, + isSuperAdmin: primaryRole.isSuperAdmin, + profile: user.profile, + businesses: user.businesses, + customerBusinesses: user.customerBusinesses, + }; + } + + private generateOtpCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } +} diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts new file mode 100644 index 0000000..0684037 --- /dev/null +++ b/src/auth/auth.types.ts @@ -0,0 +1,98 @@ +export type DashboardType = 'super_admin' | 'business' | 'customer'; + +export interface AuthJwtPayload { + sub: string; + cellNumber: string; + roles: string[]; + dashboard: DashboardType; + type: 'access' | 'refresh'; +} + +export interface BusinessMembership { + id: bigint; + name: string; + slug: string; + isOwner: boolean; + teamRole: string | null; + permissions: string[]; +} + +export interface UserProfile { + about: string; + city: string; + address: string; + landline: string; + backupPhone: string; + postalCode: string; + instagram: string; + telegramId: string; + linkedin: string; +} + +export interface AuthUser { + id: bigint; + cellNumber: string; + email: string | null; + firstName: string | null; + lastName: string | null; + cellVerifiedAt: Date | null; + roles: string[]; + dashboard: DashboardType; + profile: UserProfile; + businesses: BusinessMembership[]; + customerBusinesses: { id: bigint; name: string; slug: string }[]; +} + +/** Roles a business owner can assign to team members */ +export const ASSIGNABLE_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const; +export type AssignableTeamRole = (typeof ASSIGNABLE_TEAM_ROLES)[number]; + +/** Global roles a super admin can assign to users */ +export const ASSIGNABLE_GLOBAL_ROLES = [ + 'super_admin', + 'business_owner', + 'customer', +] as const; +export type AssignableGlobalRole = (typeof ASSIGNABLE_GLOBAL_ROLES)[number]; + +export const ROLE_LABELS: Record = { + super_admin: 'Super Admin', + business_owner: 'Business Owner', + business_staff: 'Business Staff', + customer: 'Customer', + owner: 'Business Owner', + admin: 'Admin', + editor: 'Editor', + viewer: 'Viewer', +}; + +/** Highest global role for UI display (badge in header). */ +export function resolvePrimaryRole(roles: string[]): { + slug: string; + label: string; + isSuperAdmin: boolean; +} { + if (roles.includes('super_admin')) { + return { slug: 'super_admin', label: ROLE_LABELS.super_admin, isSuperAdmin: true }; + } + if (roles.includes('business_owner') || roles.includes('owner')) { + return { + slug: 'business_owner', + label: ROLE_LABELS.business_owner, + isSuperAdmin: false, + }; + } + if (roles.includes('business_staff')) { + return { + slug: 'business_staff', + label: ROLE_LABELS.business_staff, + isSuperAdmin: false, + }; + } + if (roles.includes('customer')) { + return { slug: 'customer', label: ROLE_LABELS.customer, isSuperAdmin: false }; + } + + const slug = roles[0] ?? 'customer'; + return { slug, label: ROLE_LABELS[slug] ?? 'User', isSuperAdmin: false }; +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..db774b3 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUser } from '../auth.types'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser => { + const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>(); + return request.user; + }, +); diff --git a/src/auth/decorators/require-business-permission.decorator.ts b/src/auth/decorators/require-business-permission.decorator.ts new file mode 100644 index 0000000..fb665ab --- /dev/null +++ b/src/auth/decorators/require-business-permission.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const BUSINESS_PERMISSION_KEY = 'business_permission'; + +export const RequireBusinessPermission = (permission: string) => + SetMetadata(BUSINESS_PERMISSION_KEY, permission); diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..bd985d2 --- /dev/null +++ b/src/auth/dto/change-password.dto.ts @@ -0,0 +1,10 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @IsString() + currentPassword!: string; + + @IsString() + @MinLength(8, { message: 'newPassword must be at least 8 characters' }) + newPassword!: string; +} diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..1ae66ea --- /dev/null +++ b/src/auth/dto/login.dto.ts @@ -0,0 +1,13 @@ +import { IsString, Matches, MinLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; + + @IsString() + @MinLength(8) + password!: string; +} diff --git a/src/auth/dto/refresh-token.dto.ts b/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..752ff4f --- /dev/null +++ b/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefreshTokenDto { + @IsString() + refreshToken!: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..81469a3 --- /dev/null +++ b/src/auth/dto/register.dto.ts @@ -0,0 +1,30 @@ +import { IsEmail, IsOptional, IsString, Matches, MinLength } from 'class-validator'; + +export class RegisterDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; + + @IsString() + @MinLength(8, { message: 'password must be at least 8 characters' }) + password!: string; + + @IsString() + @MinLength(2) + firstName!: string; + + @IsString() + @MinLength(2) + lastName!: string; + + @IsOptional() + @IsEmail() + email?: string; + + /** Domain of the business website (e.g. shop-a.local). Resolves tenant for customer registration. */ + @IsString() + @MinLength(3) + domain!: string; +} diff --git a/src/auth/dto/send-otp.dto.ts b/src/auth/dto/send-otp.dto.ts new file mode 100644 index 0000000..1aadd15 --- /dev/null +++ b/src/auth/dto/send-otp.dto.ts @@ -0,0 +1,9 @@ +import { IsString, Matches } from 'class-validator'; + +export class SendOtpDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; +} diff --git a/src/auth/dto/update-profile.dto.ts b/src/auth/dto/update-profile.dto.ts new file mode 100644 index 0000000..3f31f71 --- /dev/null +++ b/src/auth/dto/update-profile.dto.ts @@ -0,0 +1,63 @@ +import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(1000) + about?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + address?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + landline?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + backupPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + instagram?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + telegramId?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + linkedin?: string; +} diff --git a/src/auth/dto/upsert-user-address.dto.ts b/src/auth/dto/upsert-user-address.dto.ts new file mode 100644 index 0000000..7c557b4 --- /dev/null +++ b/src/auth/dto/upsert-user-address.dto.ts @@ -0,0 +1,33 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class UpsertUserAddressDto { + @IsOptional() + @IsString() + @MaxLength(100) + label?: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + province!: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + city!: string; + + @IsString() + @MinLength(1) + @MaxLength(500) + address!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + landline?: string; +} diff --git a/src/auth/dto/verify-otp.dto.ts b/src/auth/dto/verify-otp.dto.ts new file mode 100644 index 0000000..cc4da6c --- /dev/null +++ b/src/auth/dto/verify-otp.dto.ts @@ -0,0 +1,14 @@ +import { IsString, Length, Matches } from 'class-validator'; + +export class VerifyOtpDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; + + @IsString() + @Length(6, 6) + @Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' }) + code!: string; +} diff --git a/src/auth/guards/business-permission.guard.ts b/src/auth/guards/business-permission.guard.ts new file mode 100644 index 0000000..75366c1 --- /dev/null +++ b/src/auth/guards/business-permission.guard.ts @@ -0,0 +1,53 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthUser } from '../auth.types'; +import { BUSINESS_PERMISSION_KEY } from '../decorators/require-business-permission.decorator'; +import { PermissionsService } from '../permissions.service'; + +@Injectable() +export class BusinessPermissionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly permissions: PermissionsService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const permission = this.reflector.get( + BUSINESS_PERMISSION_KEY, + context.getHandler(), + ); + + if (!permission) { + return true; + } + + const request = context.switchToHttp().getRequest<{ + user: AuthUser; + params: { businessId?: string }; + }>(); + + const businessIdRaw = request.params.businessId; + if (!businessIdRaw) { + throw new ForbiddenException('Business context is required'); + } + + const allowed = await this.permissions.hasBusinessPermission( + request.user.id, + BigInt(businessIdRaw), + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + + return true; + } +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/permissions.service.ts b/src/auth/permissions.service.ts new file mode 100644 index 0000000..8598a9e --- /dev/null +++ b/src/auth/permissions.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class PermissionsService { + constructor(private readonly prisma: PrismaService) {} + + async isSuperAdmin(userId: bigint): Promise { + const count = await this.prisma.userRole.count({ + where: { + userId, + role: { slug: 'super_admin' }, + }, + }); + return count > 0; + } + + async getBusinessMemberships(userId: bigint) { + const memberships = await this.prisma.businessUser.findMany({ + where: { userId }, + include: { business: true, role: true }, + }); + + return Promise.all( + memberships.map(async (m) => ({ + id: m.business.id, + name: m.business.name, + slug: m.business.slug, + isOwner: m.isOwner, + teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null, + permissions: await this.getPermissionsForBusiness(userId, m.businessId), + })), + ); + } + + async getPermissionsForBusiness( + userId: bigint, + businessId: bigint, + ): Promise { + if (await this.isSuperAdmin(userId)) { + return this.getAllPermissionSlugs(); + } + + const membership = await this.prisma.businessUser.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + include: { + role: { + include: { + rolePermissions: { include: { permission: true } }, + }, + }, + }, + }); + + if (!membership) { + return []; + } + + if (membership.isOwner) { + return this.getRolePermissionSlugs('business_owner'); + } + + if (!membership.role) { + return []; + } + + return membership.role.rolePermissions.map((rp) => rp.permission.slug); + } + + async hasBusinessPermission( + userId: bigint, + businessId: bigint, + permission: string, + ): Promise { + const permissions = await this.getPermissionsForBusiness(userId, businessId); + return permissions.includes(permission); + } + + private async getRolePermissionSlugs(roleSlug: string): Promise { + const role = await this.prisma.role.findUnique({ + where: { slug: roleSlug }, + include: { + rolePermissions: { include: { permission: true } }, + }, + }); + + return role?.rolePermissions.map((rp) => rp.permission.slug) ?? []; + } + + private async getAllPermissionSlugs(): Promise { + const permissions = await this.prisma.permission.findMany({ + select: { slug: true }, + }); + return permissions.map((p) => p.slug); + } +} diff --git a/src/auth/profile.util.ts b/src/auth/profile.util.ts new file mode 100644 index 0000000..e7a4551 --- /dev/null +++ b/src/auth/profile.util.ts @@ -0,0 +1,18 @@ +import { UserProfile } from './auth.types'; + +export function parseUserProfile(value: unknown): UserProfile { + const source = + value && typeof value === 'object' ? (value as Record) : {}; + + return { + about: typeof source.about === 'string' ? source.about : '', + city: typeof source.city === 'string' ? source.city : '', + address: typeof source.address === 'string' ? source.address : '', + landline: typeof source.landline === 'string' ? source.landline : '', + backupPhone: typeof source.backupPhone === 'string' ? source.backupPhone : '', + postalCode: typeof source.postalCode === 'string' ? source.postalCode : '', + instagram: typeof source.instagram === 'string' ? source.instagram : '', + telegramId: typeof source.telegramId === 'string' ? source.telegramId : '', + linkedin: typeof source.linkedin === 'string' ? source.linkedin : '', + }; +} diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts new file mode 100644 index 0000000..9bc82e9 --- /dev/null +++ b/src/auth/sms.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor(private readonly config: ConfigService) {} + + isEnabled(): boolean { + return this.config.get('SMS_ENABLED', 'false') === 'true'; + } + + async sendVerificationCode(cellNumber: string, code: string): Promise { + if (!this.isEnabled()) { + this.logger.warn( + `SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`, + ); + return; + } + + // TODO: integrate real SMS provider when API credentials are available + this.logger.log(`Sending SMS verification code to ${cellNumber}`); + throw new Error('SMS provider is not configured yet'); + } + + async sendMessage(cellNumber: string, message: string): Promise { + if (!this.isEnabled()) { + this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`); + return; + } + + // TODO: integrate real SMS provider when API credentials are available + this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`); + throw new Error('SMS provider is not configured yet'); + } +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..7e5deed --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,82 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PrismaService } from '../../prisma/prisma.service'; +import { + AuthJwtPayload, + AuthUser, + DashboardType, +} from '../auth.types'; +import { PermissionsService } from '../permissions.service'; +import { parseUserProfile } from '../profile.util'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'), + }); + } + + async validate(payload: AuthJwtPayload): Promise { + if (payload.type !== 'access') { + throw new UnauthorizedException('Invalid token type'); + } + + const user = await this.prisma.user.findUnique({ + where: { id: BigInt(payload.sub) }, + include: { + userRoles: { include: { role: true } }, + businessCustomers: { include: { business: true } }, + }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); + } + + const roles = user.userRoles.map((ur) => ur.role.slug); + const businesses = await this.permissions.getBusinessMemberships(user.id); + const customerBusinesses = user.businessCustomers.map((bc) => ({ + id: bc.business.id, + name: bc.business.name, + slug: bc.business.slug, + })); + + return { + id: user.id, + cellNumber: user.cellNumber, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + cellVerifiedAt: user.cellVerifiedAt, + roles, + dashboard: this.resolveDashboard(roles, businesses.length), + profile: parseUserProfile(user.profile), + businesses, + customerBusinesses, + }; + } + + private resolveDashboard(roles: string[], businessCount: number): DashboardType { + if (roles.includes('super_admin')) { + return 'super_admin'; + } + if ( + roles.includes('business_owner') || + roles.includes('business_staff') || + roles.includes('owner') || + businessCount > 0 + ) { + return 'business'; + } + return 'customer'; + } +} diff --git a/src/auth/user-addresses.service.ts b/src/auth/user-addresses.service.ts new file mode 100644 index 0000000..74240fb --- /dev/null +++ b/src/auth/user-addresses.service.ts @@ -0,0 +1,109 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthUser } from './auth.types'; +import { UpsertUserAddressDto } from './dto/upsert-user-address.dto'; + +@Injectable() +export class UserAddressesService { + constructor(private readonly prisma: PrismaService) {} + + async list(actor: AuthUser) { + const items = await this.prisma.address.findMany({ + where: { userId: actor.id, businessId: null }, + orderBy: { createdAt: 'asc' }, + }); + + return { items: items.map((item) => this.serialize(item)) }; + } + + async create(actor: AuthUser, dto: UpsertUserAddressDto) { + const created = await this.prisma.address.create({ + data: { + userId: actor.id, + label: dto.label?.trim() || null, + province: dto.province.trim(), + city: dto.city.trim(), + address: dto.address.trim(), + postalCode: dto.postalCode?.trim() || null, + landline: dto.landline?.trim() || null, + }, + }); + + return { address: this.serialize(created) }; + } + + async update( + actor: AuthUser, + addressIdRaw: string, + dto: UpsertUserAddressDto, + ) { + const address = await this.findOwnedAddress(actor, addressIdRaw); + + const updated = await this.prisma.address.update({ + where: { id: address.id }, + data: { + label: dto.label?.trim() || null, + province: dto.province.trim(), + city: dto.city.trim(), + address: dto.address.trim(), + postalCode: dto.postalCode?.trim() || null, + landline: dto.landline?.trim() || null, + }, + }); + + return { address: this.serialize(updated) }; + } + + async remove(actor: AuthUser, addressIdRaw: string) { + const address = await this.findOwnedAddress(actor, addressIdRaw); + + await this.prisma.address.delete({ + where: { id: address.id }, + }); + + return { message: 'Address removed.' }; + } + + private async findOwnedAddress(actor: AuthUser, addressIdRaw: string) { + const address = await this.prisma.address.findFirst({ + where: { + id: BigInt(addressIdRaw), + userId: actor.id, + businessId: null, + }, + }); + + if (!address) { + throw new NotFoundException('Address not found'); + } + + return address; + } + + private serialize(address: { + id: bigint; + label: string | null; + province: string; + city: string; + address: string; + postalCode: string | null; + landline: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: address.id.toString(), + label: address.label, + province: address.province, + city: address.city, + address: address.address, + postalCode: address.postalCode, + landline: address.landline, + createdAt: address.createdAt, + updatedAt: address.updatedAt, + }; + } +} diff --git a/src/blogs/blogs.controller.ts b/src/blogs/blogs.controller.ts new file mode 100644 index 0000000..2317bd6 --- /dev/null +++ b/src/blogs/blogs.controller.ts @@ -0,0 +1,120 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BlogsService } from './blogs.service'; +import { + CreateBlogCommentDto, + CreateBlogDto, + ListBlogsDto, + ListPublicBlogsDto, + UpdateBlogDto, +} from './dto/blog.dto'; + +@Controller('businesses/:businessId/blogs') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class BlogsController { + constructor(private readonly service: BlogsService) {} + + @Get() + @RequireBusinessPermission('blogs.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListBlogsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':blogId') + @RequireBusinessPermission('blogs.read') + getOne( + @Param('businessId') businessId: string, + @Param('blogId') blogId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, blogId, user); + } + + @Post() + @RequireBusinessPermission('blogs.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateBlogDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':blogId') + @RequireBusinessPermission('blogs.update') + update( + @Param('businessId') businessId: string, + @Param('blogId') blogId: string, + @Body() dto: UpdateBlogDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, blogId, dto, user); + } + + @Delete(':blogId') + @RequireBusinessPermission('blogs.delete') + remove( + @Param('businessId') businessId: string, + @Param('blogId') blogId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, blogId, user); + } + + @Get(':blogId/comments') + @RequireBusinessPermission('comments.read') + listComments( + @Param('businessId') businessId: string, + @Param('blogId') blogId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.listCommentsAdmin(businessId, blogId, user); + } +} + +@Controller('tenants/:host/blogs') +export class PublicBlogsController { + constructor(private readonly service: BlogsService) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) { + return this.service.listPublic(host, query); + } + + @Get(':blogId/comments') + listComments(@Param('host') host: string, @Param('blogId') blogId: string) { + return this.service.listCommentsPublic(host, blogId); + } + + @Post(':blogId/comments') + createComment( + @Param('host') host: string, + @Param('blogId') blogId: string, + @Body() dto: CreateBlogCommentDto, + ) { + return this.service.createCommentPublic(host, blogId, dto); + } + + @Get(':slug') + getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + return this.service.getPublicBySlug(host, slug); + } +} diff --git a/src/blogs/blogs.module.ts b/src/blogs/blogs.module.ts new file mode 100644 index 0000000..9b876d2 --- /dev/null +++ b/src/blogs/blogs.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { BlogsController, PublicBlogsController } from './blogs.controller'; +import { BlogsService } from './blogs.service'; + +@Module({ + imports: [AuthModule, BusinessSettingsModule, TenantModule], + controllers: [BlogsController, PublicBlogsController], + providers: [BlogsService], +}) +export class BlogsModule {} diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts new file mode 100644 index 0000000..92082d8 --- /dev/null +++ b/src/blogs/blogs.service.ts @@ -0,0 +1,733 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + ContentStatus, + MediaEntityType, + Prisma, +} from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { BusinessSettingsService } from '../business-settings/business-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateBlogCommentDto, + CreateBlogDto, + ListBlogsDto, + ListPublicBlogsDto, + UpdateBlogDto, +} from './dto/blog.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'blog' + ); +} + +type BlogWithRelations = Prisma.blogsGetPayload<{ + include: { + media: true; + users: { select: { id: true; firstName: true; lastName: true; email: true } }; + }; +}>; + +const blogInclude = { + media: true, + users: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, +} satisfies Prisma.blogsInclude; + +@Injectable() +export class BlogsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + private readonly businessSettings: BusinessSettingsService, + ) {} + + async list(businessIdRaw: string, query: ListBlogsDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'blogs.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + + const where = await this.buildWhere(businessId, query); + + const [items, total] = await Promise.all([ + this.prisma.blogs.findMany({ + where, + orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], + skip, + take: pageSize, + include: blogInclude, + }), + this.prisma.blogs.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => this.serializeBlog(item, { includeComments: true })), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getOne(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const blogId = BigInt(blogIdRaw); + await this.assertPermission(businessId, actor.id, 'blogs.read'); + + const blog = await this.findBlogOrThrow(businessId, blogId); + + return { blog: await this.serializeBlog(blog, { includeComments: true }) }; + } + + async create(businessIdRaw: string, dto: CreateBlogDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'blogs.create'); + + const slug = await this.ensureUniqueSlug( + businessId, + dto.slug ?? slugify(dto.title), + ); + + const status = dto.status ?? ContentStatus.draft; + const featuredMediaId = dto.featuredMediaId + ? BigInt(dto.featuredMediaId) + : null; + + if (featuredMediaId) { + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const authorId = dto.authorId ? BigInt(dto.authorId) : actor.id; + await this.assertAuthorBelongsToBusiness(businessId, authorId); + + if (dto.categoryId) { + await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId)); + } + + const created = await this.prisma.$transaction(async (tx) => { + const blog = await tx.blogs.create({ + data: { + business_id: businessId, + author_id: authorId, + title: dto.title.trim(), + slug, + excerpt: dto.abstract?.trim() || null, + content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue, + post_type: dto.type, + status, + featured_media_id: featuredMediaId, + published_at: status === ContentStatus.published ? new Date() : null, + metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue, + }, + include: blogInclude, + }); + + if (dto.categoryId) { + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId: BigInt(dto.categoryId), + entityType: MediaEntityType.blog, + entityId: blog.id, + }, + }); + } + + return blog; + }); + + return { + message: 'Blog post created successfully', + blog: await this.serializeBlog(created), + }; + } + + async update( + businessIdRaw: string, + blogIdRaw: string, + dto: UpdateBlogDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const blogId = BigInt(blogIdRaw); + await this.assertPermission(businessId, actor.id, 'blogs.update'); + + const existing = await this.prisma.blogs.findFirst({ + where: { id: blogId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Blog post not found'); + } + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug(businessId, dto.slug, blogId); + } else if (dto.title && dto.title !== existing.title) { + slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), blogId); + } + + let featuredMediaId: bigint | null | undefined = undefined; + if (dto.featuredMediaId !== undefined) { + if (dto.featuredMediaId === null || dto.featuredMediaId === '') { + featuredMediaId = null; + } else { + featuredMediaId = BigInt(dto.featuredMediaId); + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + } + + let authorId: bigint | null | undefined = undefined; + if (dto.authorId !== undefined) { + if (dto.authorId === null || dto.authorId === '') { + authorId = null; + } else { + authorId = BigInt(dto.authorId); + await this.assertAuthorBelongsToBusiness(businessId, authorId); + } + } + + const existingContent = this.asRecord(existing.content); + const existingMetadata = this.asRecord(existing.metadata); + + const nextContent = { ...existingContent }; + if (dto.mainTextHtml !== undefined) { + nextContent.html = dto.mainTextHtml ?? ''; + } + + const nextMetadata = { ...existingMetadata }; + if (dto.tags !== undefined) { + nextMetadata.tags = dto.tags; + } + + let publishedAt: Date | null | undefined = undefined; + if (dto.status !== undefined) { + if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) { + publishedAt = new Date(); + } + if (dto.status !== ContentStatus.published) { + publishedAt = null; + } + } + + const updated = await this.prisma.$transaction(async (tx) => { + const blog = await tx.blogs.update({ + where: { id: blogId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.abstract !== undefined + ? { excerpt: dto.abstract?.trim() || null } + : {}), + ...(dto.type !== undefined ? { post_type: dto.type } : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(featuredMediaId !== undefined ? { featured_media_id: featuredMediaId } : {}), + ...(authorId !== undefined ? { author_id: authorId } : {}), + ...(publishedAt !== undefined ? { published_at: publishedAt } : {}), + slug, + content: nextContent as Prisma.InputJsonValue, + metadata: nextMetadata as Prisma.InputJsonValue, + }, + include: blogInclude, + }); + + if (dto.categoryId !== undefined) { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + }, + }); + + if (dto.categoryId) { + const categoryId = BigInt(dto.categoryId); + await this.assertCategoryBelongsToBusiness(businessId, categoryId); + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.blog, + entityId: blogId, + }, + }); + } + } + + return blog; + }); + + return { + message: 'Blog post updated successfully', + blog: await this.serializeBlog(updated), + }; + } + + async remove(businessIdRaw: string, blogIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const blogId = BigInt(blogIdRaw); + await this.assertPermission(businessId, actor.id, 'blogs.delete'); + + const existing = await this.prisma.blogs.findFirst({ + where: { id: blogId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Blog post not found'); + } + + await this.prisma.$transaction([ + this.prisma.comment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + }, + }), + this.prisma.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + }, + }), + this.prisma.blogs.delete({ where: { id: blogId } }), + ]); + + return { message: 'Blog post deleted successfully' }; + } + + async listPublic(host: string, query: ListPublicBlogsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + + const where = await this.buildWhere(businessId, { + ...query, + status: ContentStatus.published, + }); + + const [items, total] = await Promise.all([ + this.prisma.blogs.findMany({ + where, + orderBy: [{ published_at: 'desc' }, { created_at: 'desc' }], + skip, + take: pageSize, + include: blogInclude, + }), + this.prisma.blogs.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => + this.serializeBlog(item, { includeComments: true, approvedCommentsOnly: true }), + ), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getPublicBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const blog = await this.prisma.blogs.findFirst({ + where: { + business_id: businessId, + slug, + status: ContentStatus.published, + }, + include: blogInclude, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + + return { + blog: await this.serializeBlog(blog, { + includeComments: true, + approvedCommentsOnly: true, + }), + }; + } + + async listCommentsPublic(host: string, blogIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const blogId = BigInt(blogIdRaw); + + await this.assertPublishedBlogExists(businessId, blogId); + + const items = await this.prisma.comment.findMany({ + where: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + isApproved: true, + }, + orderBy: { createdAt: 'desc' }, + include: { approver: true }, + }); + + return { items: items.map((item) => this.serializeComment(item)) }; + } + + async createCommentPublic( + host: string, + blogIdRaw: string, + dto: CreateBlogCommentDto, + ) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const blogId = BigInt(blogIdRaw); + + await this.assertPublishedBlogExists(businessId, blogId); + + const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId); + const approvedAt = autoApprove ? new Date() : null; + + const created = await this.prisma.comment.create({ + data: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + authorName: dto.authorName.trim(), + authorEmail: dto.authorEmail?.trim() || null, + text: dto.text.trim(), + isApproved: autoApprove, + approvedAt, + }, + include: { approver: true }, + }); + + return { + comment: this.serializeComment(created), + message: autoApprove + ? 'Comment submitted and is approved' + : 'Comment submitted and is pending approval', + }; + } + + async listCommentsAdmin( + businessIdRaw: string, + blogIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const blogId = BigInt(blogIdRaw); + await this.assertPermission(businessId, actor.id, 'comments.read'); + + const blog = await this.prisma.blogs.findFirst({ + where: { id: blogId, business_id: businessId }, + select: { id: true }, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + + const items = await this.prisma.comment.findMany({ + where: { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + }, + orderBy: { createdAt: 'desc' }, + include: { approver: true }, + }); + + return { items: items.map((item) => this.serializeComment(item)) }; + } + + private async buildWhere( + businessId: bigint, + query: (ListBlogsDto | ListPublicBlogsDto) & { status?: ContentStatus }, + ): Promise { + let entityIds: bigint[] | undefined; + + if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.blog, + }, + select: { entityId: true }, + }); + + entityIds = assignments.map((item) => item.entityId); + + if (entityIds.length === 0) { + return { id: { in: [] } }; + } + } + + return { + business_id: businessId, + ...(query.status ? { status: query.status } : {}), + ...(query.type ? { post_type: query.type } : {}), + ...(entityIds ? { id: { in: entityIds } } : {}), + ...(query.title?.trim() + ? { + title: { contains: query.title.trim(), mode: 'insensitive' }, + } + : {}), + }; + } + + private async findBlogOrThrow(businessId: bigint, blogId: bigint) { + const blog = await this.prisma.blogs.findFirst({ + where: { id: blogId, business_id: businessId }, + include: blogInclude, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + + return blog; + } + + private async assertPublishedBlogExists(businessId: bigint, blogId: bigint) { + const blog = await this.prisma.blogs.findFirst({ + where: { + id: blogId, + business_id: businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + } + + private async serializeBlog( + blog: BlogWithRelations, + options: { + includeComments?: boolean; + approvedCommentsOnly?: boolean; + } = {}, + ) { + const content = this.asRecord(blog.content); + const metadata = this.asRecord(blog.metadata); + + const [categoryAssignment, commentData] = await Promise.all([ + this.prisma.categoryAssignment.findFirst({ + where: { + businessId: blog.business_id, + entityType: MediaEntityType.blog, + entityId: blog.id, + }, + include: { category: true }, + }), + options.includeComments + ? this.loadComments(blog.business_id, blog.id, options.approvedCommentsOnly) + : Promise.resolve({ commentCount: 0, comments: [] }), + ]); + + return { + id: blog.id.toString(), + businessId: blog.business_id.toString(), + title: blog.title, + slug: blog.slug, + type: blog.post_type, + abstract: blog.excerpt ?? '', + mainTextHtml: (content.html as string | undefined) ?? '', + status: blog.status, + categoryId: categoryAssignment?.categoryId.toString() ?? null, + categoryName: categoryAssignment?.category.name ?? '', + tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], + authorId: blog.author_id?.toString() ?? null, + author: blog.users + ? { + id: blog.users.id.toString(), + firstName: blog.users.firstName, + lastName: blog.users.lastName, + email: blog.users.email, + } + : null, + titleImageUrl: blog.media?.publicUrl ?? null, + featuredMediaId: blog.featured_media_id?.toString() ?? null, + commentCount: commentData.commentCount, + comments: commentData.comments, + publishedAt: blog.published_at, + createdAt: blog.created_at, + updatedAt: blog.updated_at, + }; + } + + private async loadComments( + businessId: bigint, + blogId: bigint, + approvedOnly?: boolean, + ) { + const where: Prisma.CommentWhereInput = { + businessId, + entityType: MediaEntityType.blog, + entityId: blogId, + ...(approvedOnly ? { isApproved: true } : {}), + }; + + const [commentCount, comments] = await Promise.all([ + this.prisma.comment.count({ where }), + this.prisma.comment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: approvedOnly ? 50 : undefined, + include: { approver: true }, + }), + ]); + + return { + commentCount, + comments: comments.map((item) => this.serializeComment(item)), + }; + } + + private serializeComment( + comment: Prisma.CommentGetPayload<{ include: { approver: true } }>, + ) { + return { + id: comment.id.toString(), + businessId: comment.businessId.toString(), + entityType: comment.entityType, + entityId: comment.entityId.toString(), + authorName: comment.authorName, + authorEmail: comment.authorEmail, + text: comment.text, + isApproved: comment.isApproved, + approvedAt: comment.approvedAt, + approvedBy: comment.approvedBy?.toString() ?? null, + approver: comment.approver + ? { + id: comment.approver.id.toString(), + firstName: comment.approver.firstName, + lastName: comment.approver.lastName, + } + : null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + } + + private buildContent(mainTextHtml?: string) { + return { + html: mainTextHtml ?? '', + }; + } + + private buildMetadata(tags?: string[]) { + return { + tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [], + }; + } + + private asRecord(value: Prisma.JsonValue): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + if (!media) { + throw new BadRequestException('Media not found for this business'); + } + } + + private async assertCategoryBelongsToBusiness( + businessId: bigint, + categoryId: bigint, + ) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.blog, + isActive: true, + }, + }); + if (!category) { + throw new BadRequestException('Blog category not found for this business'); + } + } + + private async assertAuthorBelongsToBusiness(businessId: bigint, authorId: bigint) { + const member = await this.prisma.businessUser.findFirst({ + where: { businessId, userId: authorId }, + }); + + if (!member) { + throw new BadRequestException('Author must be a team member of this business'); + } + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.blogs.findFirst({ + where: { + business_id: businessId, + slug, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${permission} for this business`); + } + } +} diff --git a/src/blogs/dto/blog.dto.ts b/src/blogs/dto/blog.dto.ts new file mode 100644 index 0000000..a41cf36 --- /dev/null +++ b/src/blogs/dto/blog.dto.ts @@ -0,0 +1,170 @@ +import { BlogPostType, ContentStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Min, + MinLength, +} from 'class-validator'; + +export class ListBlogsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsEnum(BlogPostType) + type?: BlogPostType; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class ListPublicBlogsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(BlogPostType) + type?: BlogPostType; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class CreateBlogDto { + @IsString() + @MinLength(2) + title!: string; + + @IsEnum(BlogPostType) + type!: BlogPostType; + + @IsOptional() + @IsString() + abstract?: string; + + @IsOptional() + @IsString() + mainTextHtml?: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + authorId?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} + +export class UpdateBlogDto { + @IsOptional() + @IsString() + @MinLength(2) + title?: string; + + @IsOptional() + @IsEnum(BlogPostType) + type?: BlogPostType; + + @IsOptional() + @IsString() + abstract?: string | null; + + @IsOptional() + @IsString() + mainTextHtml?: string | null; + + @IsOptional() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + authorId?: string | null; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} + +export class CreateBlogCommentDto { + @IsString() + @MinLength(2) + authorName!: string; + + @IsOptional() + @IsString() + authorEmail?: string; + + @IsString() + @MinLength(1) + text!: string; +} diff --git a/src/brands/brands.controller.ts b/src/brands/brands.controller.ts new file mode 100644 index 0000000..2a4d920 --- /dev/null +++ b/src/brands/brands.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BrandsService } from './brands.service'; +import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto'; + +@Controller('businesses/:businessId/brands') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class BrandsController { + constructor(private readonly service: BrandsService) {} + + @Get() + @RequireBusinessPermission('brands.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListBrandsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':brandId') + @RequireBusinessPermission('brands.read') + getOne( + @Param('businessId') businessId: string, + @Param('brandId') brandId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, brandId, user); + } + + @Post() + @RequireBusinessPermission('brands.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateBrandDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':brandId') + @RequireBusinessPermission('brands.update') + update( + @Param('businessId') businessId: string, + @Param('brandId') brandId: string, + @Body() dto: UpdateBrandDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, brandId, dto, user); + } + + @Delete(':brandId') + @RequireBusinessPermission('brands.delete') + remove( + @Param('businessId') businessId: string, + @Param('brandId') brandId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, brandId, user); + } +} diff --git a/src/brands/brands.module.ts b/src/brands/brands.module.ts new file mode 100644 index 0000000..bb2178f --- /dev/null +++ b/src/brands/brands.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BrandsController } from './brands.controller'; +import { BrandsService } from './brands.service'; + +@Module({ + imports: [AuthModule], + controllers: [BrandsController], + providers: [BrandsService], + exports: [BrandsService], +}) +export class BrandsModule {} diff --git a/src/brands/brands.service.ts b/src/brands/brands.service.ts new file mode 100644 index 0000000..8696479 --- /dev/null +++ b/src/brands/brands.service.ts @@ -0,0 +1,284 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateBrandDto, ListBrandsDto, UpdateBrandDto } from './dto/brand.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'brand' + ); +} + +type BrandWithImage = Prisma.BrandGetPayload<{ + include: { imageMedia: true }; +}>; + +@Injectable() +export class BrandsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list(businessIdRaw: string, query: ListBrandsDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'brands.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.BrandWhereInput = { + businessId, + ...(query.name?.trim() + ? { + OR: [ + { nameEn: { contains: query.name.trim(), mode: 'insensitive' } }, + { nameFa: { contains: query.name.trim(), mode: 'insensitive' } }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.brand.findMany({ + where, + orderBy: [{ sort_order: 'asc' }, { nameEn: 'asc' }, { createdAt: 'desc' }], + skip, + take: pageSize, + include: { imageMedia: true }, + }), + this.prisma.brand.count({ where }), + ]); + + return { + items: items.map((item) => this.serialize(item)), + total, + page, + pageSize, + }; + } + + async getOne(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const brandId = BigInt(brandIdRaw); + await this.assertPermission(businessId, actor.id, 'brands.read'); + + const brand = await this.findBrandOrThrow(businessId, brandId); + return { brand: this.serialize(brand) }; + } + + async create(businessIdRaw: string, dto: CreateBrandDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'brands.create'); + + const slug = await this.ensureUniqueSlug( + businessId, + dto.slug ?? slugify(dto.nameEn), + ); + + let imageMediaId: bigint | null = null; + if (dto.imageMediaId) { + imageMediaId = BigInt(dto.imageMediaId); + await this.assertBrandImageMedia(businessId, imageMediaId); + } + + const created = await this.prisma.brand.create({ + data: { + businessId, + nameEn: dto.nameEn.trim(), + nameFa: dto.nameFa?.trim() || null, + imageMediaId, + about: dto.about?.trim() || null, + slug, + sort_order: dto.sortOrder ?? 0, + }, + include: { imageMedia: true }, + }); + + return { + message: 'Brand created successfully', + brand: this.serialize(created), + }; + } + + async update( + businessIdRaw: string, + brandIdRaw: string, + dto: UpdateBrandDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const brandId = BigInt(brandIdRaw); + await this.assertPermission(businessId, actor.id, 'brands.update'); + + const existing = await this.findBrandOrThrow(businessId, brandId); + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug(businessId, dto.slug, brandId); + } else if (dto.nameEn && dto.nameEn !== existing.nameEn) { + slug = await this.ensureUniqueSlug( + businessId, + slugify(dto.nameEn), + brandId, + ); + } + + let imageMediaId: bigint | null | undefined = undefined; + if (dto.imageMediaId !== undefined) { + if (dto.imageMediaId === null || dto.imageMediaId === '') { + imageMediaId = null; + } else { + imageMediaId = BigInt(dto.imageMediaId); + await this.assertBrandImageMedia(businessId, imageMediaId); + } + } + + const updated = await this.prisma.brand.update({ + where: { id: brandId }, + data: { + ...(dto.nameEn !== undefined ? { nameEn: dto.nameEn.trim() } : {}), + ...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}), + ...(dto.about !== undefined ? { about: dto.about?.trim() || null } : {}), + ...(imageMediaId !== undefined ? { imageMediaId } : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + slug, + }, + include: { imageMedia: true }, + }); + + return { + message: 'Brand updated successfully', + brand: this.serialize(updated), + }; + } + + async remove(businessIdRaw: string, brandIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const brandId = BigInt(brandIdRaw); + await this.assertPermission(businessId, actor.id, 'brands.delete'); + + await this.findBrandOrThrow(businessId, brandId); + await this.prisma.brand.delete({ where: { id: brandId } }); + + return { message: 'Brand deleted successfully' }; + } + + async assertBrandBelongsToBusiness(businessId: bigint, brandId: bigint) { + const brand = await this.prisma.brand.findFirst({ + where: { id: brandId, businessId }, + }); + + if (!brand) { + throw new BadRequestException('Brand not found for this business'); + } + } + + serializeBrandSummary(brand: BrandWithImage | null) { + if (!brand) { + return null; + } + + return this.serialize(brand); + } + + private async findBrandOrThrow(businessId: bigint, brandId: bigint) { + const brand = await this.prisma.brand.findFirst({ + where: { id: brandId, businessId }, + include: { imageMedia: true }, + }); + + if (!brand) { + throw new NotFoundException('Brand not found'); + } + + return brand; + } + + private serialize(brand: BrandWithImage) { + return { + id: brand.id.toString(), + businessId: brand.businessId.toString(), + nameEn: brand.nameEn, + nameFa: brand.nameFa, + about: brand.about, + slug: brand.slug, + imageMediaId: brand.imageMediaId?.toString() ?? null, + imageUrl: brand.imageMedia?.publicUrl ?? null, + sortOrder: brand.sort_order, + createdAt: brand.createdAt, + updatedAt: brand.updatedAt, + }; + } + + private async assertBrandImageMedia(businessId: bigint, mediaId: bigint) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + select: { id: true, mimeType: true }, + }); + + if (!media) { + throw new BadRequestException('Brand image media not found for this business'); + } + + if (media.mimeType !== 'image/png') { + throw new BadRequestException('Brand image must be a PNG file'); + } + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.brand.findFirst({ + where: { + businessId, + slug, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/brands/dto/brand.dto.ts b/src/brands/dto/brand.dto.ts new file mode 100644 index 0000000..63fb183 --- /dev/null +++ b/src/brands/dto/brand.dto.ts @@ -0,0 +1,86 @@ +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + Matches, + Min, + MinLength, +} from 'class-validator'; + +export class ListBrandsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; +} + +export class CreateBrandDto { + @IsString() + @MinLength(2) + nameEn!: string; + + @IsOptional() + @IsString() + nameFa?: string; + + @IsOptional() + @IsString() + imageMediaId?: string; + + @IsOptional() + @IsString() + about?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class UpdateBrandDto { + @IsOptional() + @IsString() + @MinLength(2) + nameEn?: string; + + @IsOptional() + @IsString() + nameFa?: string | null; + + @IsOptional() + @IsString() + imageMediaId?: string | null; + + @IsOptional() + @IsString() + about?: string | null; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/src/business-admin/business-admin.controller.ts b/src/business-admin/business-admin.controller.ts new file mode 100644 index 0000000..c5fd911 --- /dev/null +++ b/src/business-admin/business-admin.controller.ts @@ -0,0 +1,94 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUser } from '../auth/auth.types'; +import { ListBusinessesDto } from './dto/list-businesses.dto'; +import { SearchBusinessesDto } from './dto/search-businesses.dto'; +import { CreateBusinessDto } from './dto/create-business.dto'; +import { AddDomainDto } from './dto/add-domain.dto'; +import { UpdateDomainDto } from './dto/update-domain.dto'; +import { DisableBusinessDto } from './dto/disable-business.dto'; +import { UpdateBusinessDto } from './dto/update-business.dto'; +import { BusinessAdminService } from './business-admin.service'; + +@Controller('businesses') +export class BusinessAdminController { + constructor(private readonly service: BusinessAdminService) {} + + @Get('search') + @UseGuards(JwtAuthGuard) + search(@Query() query: SearchBusinessesDto, @CurrentUser() user: AuthUser) { + return this.service.search(query, user); + } + + @Get() + @UseGuards(JwtAuthGuard) + list(@Query() query: ListBusinessesDto, @CurrentUser() user: AuthUser) { + return this.service.list(query, user); + } + + @Get(':businessId/staff') + @UseGuards(JwtAuthGuard) + listStaff(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.listStaff(businessId, user); + } + + @Get(':businessId') + @UseGuards(JwtAuthGuard) + getOne(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.getOne(businessId, user); + } + + @Post() + @UseGuards(JwtAuthGuard) + create(@Body() dto: CreateBusinessDto, @CurrentUser() user: AuthUser) { + return this.service.create(dto, user); + } + + @Patch(':businessId') + @UseGuards(JwtAuthGuard) + update( + @Param('businessId') businessId: string, + @Body() dto: UpdateBusinessDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, dto, user); + } + + @Post(':businessId/domains') + @UseGuards(JwtAuthGuard) + addDomain( + @Param('businessId') businessId: string, + @Body() dto: AddDomainDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.addDomain(businessId, dto, user); + } + + @Patch(':businessId/domains/:domainId') + @UseGuards(JwtAuthGuard) + updateDomain( + @Param('businessId') businessId: string, + @Param('domainId') domainId: string, + @Body() dto: UpdateDomainDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateDomain(businessId, domainId, dto, user); + } + + @Patch(':businessId/disable') + @UseGuards(JwtAuthGuard) + disable( + @Param('businessId') businessId: string, + @Body() dto: DisableBusinessDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.disable(businessId, dto, user); + } + + @Delete(':businessId') + @UseGuards(JwtAuthGuard) + remove(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.remove(businessId, user); + } +} diff --git a/src/business-admin/business-admin.module.ts b/src/business-admin/business-admin.module.ts new file mode 100644 index 0000000..a3082dd --- /dev/null +++ b/src/business-admin/business-admin.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessCategoriesController } from './business-categories.controller'; +import { BusinessCategoriesService } from './business-categories.service'; +import { BusinessAdminController } from './business-admin.controller'; +import { BusinessAdminService } from './business-admin.service'; + +@Module({ + imports: [AuthModule], + controllers: [BusinessAdminController, BusinessCategoriesController], + providers: [BusinessAdminService, BusinessCategoriesService], +}) +export class BusinessAdminModule {} + diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts new file mode 100644 index 0000000..6deb72e --- /dev/null +++ b/src/business-admin/business-admin.service.ts @@ -0,0 +1,646 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthUser } from '../auth/auth.types'; +import { AddDomainDto } from './dto/add-domain.dto'; +import { UpdateDomainDto } from './dto/update-domain.dto'; +import { CreateBusinessDto } from './dto/create-business.dto'; +import { DisableBusinessDto } from './dto/disable-business.dto'; +import { UpdateBusinessDto } from './dto/update-business.dto'; +import { ListBusinessesDto } from './dto/list-businesses.dto'; +import { SearchBusinessesDto } from './dto/search-businesses.dto'; +import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors'; +import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors'; + +type BusinessRow = { + id: bigint; + name: string; + nameFa: string | null; + about: string | null; + slug: string; + createdAt: Date; + isActive: boolean; + domainId: bigint | null; + domain: string | null; + sslEnabled: boolean | null; + ownerUserId: bigint | null; + ownerName: string | null; + ownerCellNumber: string | null; + primaryColor: string | null; +}; + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + +@Injectable() +export class BusinessAdminService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + private async assertSuperAdmin(actor: AuthUser) { + if (!(await this.permissions.isSuperAdmin(actor.id))) { + throw new ForbiddenException('Super admin access required'); + } + } + + async list(query: ListBusinessesDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 10; + const skip = (page - 1) * pageSize; + + const nameLike = query.name ? `%${query.name.trim()}%` : null; + const domainLike = query.domain ? `%${query.domain.trim()}%` : null; + const categoryLike = query.category ? `%${query.category.trim()}%` : null; + + const where = Prisma.sql` + WHERE 1=1 + ${nameLike ? Prisma.sql`AND (b.name ILIKE ${nameLike} OR b.name_fa ILIKE ${nameLike})` : Prisma.empty} + ${domainLike ? Prisma.sql` + AND EXISTS ( + SELECT 1 FROM domains d + WHERE d.business_id = b.id AND d.host ILIKE ${domainLike} + ) + ` : Prisma.empty} + ${categoryLike ? Prisma.sql` + AND EXISTS ( + SELECT 1 + FROM business_category_assignments bca + JOIN business_categories bc ON bc.id = bca.category_id + WHERE bca.business_id = b.id + AND (bc.slug ILIKE ${categoryLike} OR bc.name ILIKE ${categoryLike}) + ) + ` : Prisma.empty} + `; + + const [items, totalRow] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT + b.id AS "id", + b.name AS "name", + b.name_fa AS "nameFa", + b.about AS "about", + b.slug AS "slug", + b.created_at AS "createdAt", + b.is_active AS "isActive", + dom.id AS "domainId", + dom.host AS "domain", + dom.ssl_enabled AS "sslEnabled", + own."ownerUserId" AS "ownerUserId", + own."ownerName" AS "ownerName", + own."ownerCellNumber" AS "ownerCellNumber", + b.settings->'branding'->>'primaryColor' AS "primaryColor" + FROM businesses b + LEFT JOIN LATERAL ( + SELECT d.id, d.host, d.ssl_enabled + FROM domains d + WHERE d.business_id = b.id + ORDER BY d.is_primary DESC, d.created_at DESC + LIMIT 1 + ) dom ON TRUE + LEFT JOIN LATERAL ( + SELECT + u.id AS "ownerUserId", + (u.first_name || ' ' || u.last_name) AS "ownerName", + u.cell_number AS "ownerCellNumber" + FROM business_users bu + JOIN users u ON u.id = bu.user_id + WHERE bu.business_id = b.id AND bu.is_owner = TRUE + LIMIT 1 + ) own ON TRUE + ${where} + ORDER BY b.created_at DESC + LIMIT ${pageSize} OFFSET ${skip} + `), + this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` + SELECT COUNT(*)::int AS "total" + FROM businesses b + ${where} + `), + ]); + + return { + items: items.map((item) => ({ + ...item, + primaryColor: normalizeBusinessPrimaryColorId( + item.primaryColor, + ) as BusinessPrimaryColorId, + })), + total: totalRow[0]?.total ?? 0, + page, + pageSize, + }; + } + + async search(query: SearchBusinessesDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const q = query.q.trim(); + const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const like = `%${q}%`; + + const items = await this.prisma.$queryRaw< + { + id: bigint; + name: string; + nameFa: string | null; + slug: string; + }[] + >(Prisma.sql` + SELECT DISTINCT + b.id AS "id", + b.name AS "name", + b.name_fa AS "nameFa", + b.slug AS "slug" + FROM businesses b + LEFT JOIN domains d ON d.business_id = b.id + WHERE b.is_active = TRUE + AND ( + b.name ILIKE ${like} + OR b.name_fa ILIKE ${like} + OR b.slug ILIKE ${like} + OR d.host ILIKE ${like} + ) + ORDER BY b.name ASC + LIMIT ${limit} + `); + + return { + items: items.map((business) => ({ + id: business.id, + name: business.name, + nameFa: business.nameFa, + slug: business.slug, + label: this.formatBusinessLabel(business), + })), + }; + } + + async listStaff(businessIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + const members = await this.prisma.businessUser.findMany({ + where: { businessId }, + include: { + user: { + select: { + id: true, + cellNumber: true, + firstName: true, + lastName: true, + email: true, + cellVerifiedAt: true, + isActive: true, + }, + }, + role: true, + inviter: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }); + + return { + items: members.map((member) => ({ + id: member.id, + userId: member.user.id, + cellNumber: member.user.cellNumber, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + isActive: member.user.isActive, + isVerified: member.user.cellVerifiedAt !== null, + isOwner: member.isOwner, + teamRole: member.isOwner ? 'business_owner' : member.role?.slug ?? null, + invitedBy: member.inviter, + createdAt: member.createdAt, + })), + }; + } + + async getOne(businessIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + const businessId = BigInt(businessIdRaw); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + include: { + categoryAssignments: { + include: { category: true }, + }, + businessUsers: { + where: { isOwner: true }, + include: { user: true }, + take: 1, + }, + domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] }, + }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + return this.serializeBusiness(business); + } + + async create(dto: CreateBusinessDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const slug = dto.slug?.trim() || slugify(dto.name); + if (!slug) { + throw new BadRequestException('Could not generate slug from name'); + } + + await this.assertSlugAvailable(slug); + await this.validateCategoryIds(dto.categoryIds); + + const business = await this.prisma.$transaction(async (tx) => { + const owner = await this.createOwnerUser(tx, dto); + + const created = await tx.business.create({ + data: { + name: dto.name.trim(), + nameFa: dto.nameFa.trim(), + about: dto.about?.trim() ?? null, + slug, + }, + }); + + await tx.businessCategoryAssignment.createMany({ + data: dto.categoryIds.map((id) => ({ + businessId: created.id, + categoryId: BigInt(id), + })), + }); + + await this.assignOwner(tx, created.id, owner.id, actor.id); + + return created; + }); + + return this.getOne(business.id.toString(), actor); + } + + async update(businessIdRaw: string, dto: UpdateBusinessDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + const nextName = dto.name?.trim() ?? business.name; + const nextNameFa = dto.nameFa?.trim() ?? business.nameFa ?? business.name; + const nextSlug = + dto.slug?.trim() ?? (dto.name ? slugify(dto.name) : business.slug); + + if (nextSlug !== business.slug) { + await this.assertSlugAvailable(nextSlug, businessId); + } + + if (dto.categoryIds) { + await this.validateCategoryIds(dto.categoryIds); + } + + if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) { + await this.findOwnerUser(dto.ownerUserId); + } + + await this.prisma.$transaction(async (tx) => { + await tx.business.update({ + where: { id: businessId }, + data: { + name: nextName, + nameFa: nextNameFa, + about: dto.about !== undefined ? dto.about.trim() || null : undefined, + slug: nextSlug, + }, + }); + + if (dto.categoryIds) { + await tx.businessCategoryAssignment.deleteMany({ where: { businessId } }); + await tx.businessCategoryAssignment.createMany({ + data: dto.categoryIds.map((id) => ({ + businessId, + categoryId: BigInt(id), + })), + }); + } + + if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) { + await tx.businessUser.deleteMany({ + where: { businessId, isOwner: true }, + }); + await this.assignOwner(tx, businessId, BigInt(dto.ownerUserId), actor.id); + } + }); + + return this.getOne(businessIdRaw, actor); + } + + async addDomain(businessIdRaw: string, dto: AddDomainDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const host = dto.host.trim(); + + if (!host) { + throw new BadRequestException('host is required'); + } + + const business = await this.prisma.business.findUnique({ where: { id: businessId } }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + const hasPrimary = await this.prisma.domain.findFirst({ + where: { businessId, isPrimary: true }, + select: { id: true }, + }); + + const isPrimary = dto.isPrimary ?? !hasPrimary; + + return this.prisma.domain.create({ + data: { + businessId, + host, + isPrimary, + isVerified: false, + sslEnabled: false, + }, + }); + } + + async updateDomain( + businessIdRaw: string, + domainIdRaw: string, + dto: UpdateDomainDto, + actor: AuthUser, + ) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const domainId = BigInt(domainIdRaw); + const host = dto.host.trim(); + + if (!host) { + throw new BadRequestException('host is required'); + } + + const domain = await this.prisma.domain.findFirst({ + where: { id: domainId, businessId }, + }); + + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + const existing = await this.prisma.domain.findUnique({ where: { host } }); + if (existing && existing.id !== domainId) { + throw new ConflictException('Domain host is already taken'); + } + + return this.prisma.domain.update({ + where: { id: domainId }, + data: { host }, + }); + } + + async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const business = await this.prisma.business.findUnique({ where: { id: businessId } }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + return this.prisma.business.update({ + where: { id: businessId }, + data: { isActive: dto.isActive }, + }); + } + + async remove(businessIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(businessIdRaw); + const business = await this.prisma.business.findUnique({ where: { id: businessId } }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + await this.prisma.business.delete({ where: { id: businessId } }); + + return { message: 'Business removed' }; + } + + private async assertSlugAvailable(slug: string, excludeId?: bigint) { + const existing = await this.prisma.business.findUnique({ where: { slug } }); + if (existing && existing.id !== excludeId) { + throw new ConflictException('Business slug is already taken'); + } + } + + private async validateCategoryIds(categoryIds: number[]) { + const ids = [...new Set(categoryIds)].map((id) => BigInt(id)); + const count = await this.prisma.businessCategory.count({ + where: { id: { in: ids }, isActive: true }, + }); + if (count !== ids.length) { + throw new BadRequestException('One or more categoryIds are invalid'); + } + } + + private async createOwnerUser( + tx: Prisma.TransactionClient, + dto: Pick< + CreateBusinessDto, + 'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword' + >, + ) { + const existing = await tx.user.findUnique({ + where: { cellNumber: dto.ownerCellNumber }, + }); + + if (existing) { + throw new ConflictException('A user with this cell number already exists'); + } + + const passwordHash = await bcrypt.hash(dto.ownerPassword, 10); + + return tx.user.create({ + data: { + cellNumber: dto.ownerCellNumber, + passwordHash, + firstName: dto.ownerFirstName.trim(), + lastName: dto.ownerLastName.trim(), + cellVerifiedAt: new Date(), + }, + }); + } + + private async findOwnerUser(ownerUserId: number) { + const owner = await this.prisma.user.findUnique({ + where: { id: BigInt(ownerUserId) }, + }); + if (!owner || !owner.isActive) { + throw new NotFoundException('Owner user not found'); + } + return owner; + } + + private async assignOwner( + tx: Prisma.TransactionClient, + businessId: bigint, + ownerUserId: bigint, + invitedBy: bigint, + ) { + const businessOwnerRole = await tx.role.findUnique({ + where: { slug: 'business_owner' }, + }); + if (!businessOwnerRole) { + throw new Error('business_owner role is missing'); + } + + await tx.businessUser.upsert({ + where: { + businessId_userId: { businessId, userId: ownerUserId }, + }, + create: { + businessId, + userId: ownerUserId, + isOwner: true, + invitedBy, + }, + update: { + isOwner: true, + roleId: null, + invitedBy, + }, + }); + + const hasRole = await tx.userRole.findUnique({ + where: { + userId_roleId: { + userId: ownerUserId, + roleId: businessOwnerRole.id, + }, + }, + }); + + if (!hasRole) { + await tx.userRole.create({ + data: { userId: ownerUserId, roleId: businessOwnerRole.id }, + }); + } + } + + private serializeBusiness( + business: { + id: bigint; + name: string; + nameFa: string | null; + about: string | null; + slug: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + categoryAssignments: { + category: { + id: bigint; + name: string; + slug: string; + parentId: bigint | null; + }; + }[]; + businessUsers: { + user: { + id: bigint; + cellNumber: string; + firstName: string | null; + lastName: string | null; + email: string | null; + }; + }[]; + domains: { + id: bigint; + host: string; + isPrimary: boolean; + isVerified: boolean; + sslEnabled: boolean; + }[]; + }, + ) { + const owner = business.businessUsers[0]?.user ?? null; + + return { + id: business.id, + name: business.name, + nameFa: business.nameFa, + about: business.about, + slug: business.slug, + isActive: business.isActive, + createdAt: business.createdAt, + updatedAt: business.updatedAt, + categories: business.categoryAssignments.map((a) => ({ + id: a.category.id, + name: a.category.name, + slug: a.category.slug, + parentId: a.category.parentId, + })), + categoryIds: business.categoryAssignments.map((a) => a.category.id), + owner: owner + ? { + id: owner.id, + cellNumber: owner.cellNumber, + firstName: owner.firstName, + lastName: owner.lastName, + email: owner.email, + } + : null, + ownerUserId: owner?.id ?? null, + domains: business.domains, + }; + } + + private formatBusinessLabel(business: { + name: string; + nameFa: string | null; + slug: string; + }): string { + if (business.nameFa && business.nameFa !== business.name) { + return `${business.name} / ${business.nameFa}`; + } + return business.name; + } +} diff --git a/src/business-admin/business-categories.controller.ts b/src/business-admin/business-categories.controller.ts new file mode 100644 index 0000000..6a02c67 --- /dev/null +++ b/src/business-admin/business-categories.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AuthUser } from '../auth/auth.types'; +import { BusinessCategoriesService } from './business-categories.service'; + +@Controller('business-categories') +export class BusinessCategoriesController { + constructor(private readonly service: BusinessCategoriesService) {} + + @Get() + @UseGuards(JwtAuthGuard) + list(@CurrentUser() user: AuthUser) { + return this.service.list(user); + } +} diff --git a/src/business-admin/business-categories.service.ts b/src/business-admin/business-categories.service.ts new file mode 100644 index 0000000..1cebb94 --- /dev/null +++ b/src/business-admin/business-categories.service.ts @@ -0,0 +1,40 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { PermissionsService } from '../auth/permissions.service'; +import { AuthUser } from '../auth/auth.types'; + +@Injectable() +export class BusinessCategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list(actor: AuthUser) { + const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id); + const canRead = + isSuperAdmin || + actor.roles.includes('business_owner') || + actor.roles.includes('business_staff'); + + if (!canRead) { + throw new ForbiddenException('Access denied'); + } + + const categories = await this.prisma.businessCategory.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + select: { + id: true, + parentId: true, + name: true, + slug: true, + description: true, + icon: true, + sortOrder: true, + }, + }); + + return { items: categories }; + } +} diff --git a/src/business-admin/dto/add-domain.dto.ts b/src/business-admin/dto/add-domain.dto.ts new file mode 100644 index 0000000..9b46df7 --- /dev/null +++ b/src/business-admin/dto/add-domain.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'; + +export class AddDomainDto { + @IsString() + @MinLength(1) + host!: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; +} + diff --git a/src/business-admin/dto/create-business.dto.ts b/src/business-admin/dto/create-business.dto.ts new file mode 100644 index 0000000..64227c5 --- /dev/null +++ b/src/business-admin/dto/create-business.dto.ts @@ -0,0 +1,55 @@ +import { + ArrayMinSize, + IsArray, + IsInt, + IsOptional, + IsString, + Matches, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateBusinessDto { + @IsString() + @MinLength(2) + nameFa!: string; + + @IsString() + @MinLength(2) + name!: string; + + @IsOptional() + @IsString() + about?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase letters, numbers, and hyphens', + }) + slug?: string; + + @IsArray() + @ArrayMinSize(1) + @Type(() => Number) + @IsInt({ each: true }) + categoryIds!: number[]; + + @IsString() + @MinLength(2) + ownerFirstName!: string; + + @IsString() + @MinLength(2) + ownerLastName!: string; + + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'ownerCellNumber must be in E.164 format (e.g. +989121234567)', + }) + ownerCellNumber!: string; + + @IsString() + @MinLength(8) + ownerPassword!: string; +} diff --git a/src/business-admin/dto/disable-business.dto.ts b/src/business-admin/dto/disable-business.dto.ts new file mode 100644 index 0000000..30e0801 --- /dev/null +++ b/src/business-admin/dto/disable-business.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean } from 'class-validator'; + +export class DisableBusinessDto { + @IsBoolean() + isActive!: boolean; +} + diff --git a/src/business-admin/dto/list-businesses.dto.ts b/src/business-admin/dto/list-businesses.dto.ts new file mode 100644 index 0000000..d4070ae --- /dev/null +++ b/src/business-admin/dto/list-businesses.dto.ts @@ -0,0 +1,30 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListBusinessesDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(5) + @Max(50) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + domain?: string; + + @IsOptional() + @IsString() + category?: string; +} + diff --git a/src/business-admin/dto/search-businesses.dto.ts b/src/business-admin/dto/search-businesses.dto.ts new file mode 100644 index 0000000..c319d17 --- /dev/null +++ b/src/business-admin/dto/search-businesses.dto.ts @@ -0,0 +1,12 @@ +import { Type } from 'class-transformer'; +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class SearchBusinessesDto { + @IsString() + @MinLength(2, { message: 'q must be at least 2 characters' }) + q!: string; + + @IsOptional() + @Type(() => Number) + limit?: number = 20; +} diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts new file mode 100644 index 0000000..bdabaa0 --- /dev/null +++ b/src/business-admin/dto/update-business.dto.ts @@ -0,0 +1,47 @@ +import { + ArrayMinSize, + IsArray, + IsInt, + IsOptional, + IsString, + Matches, + MinLength, + ValidateIf, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateBusinessDto { + @IsOptional() + @IsString() + @MinLength(2) + nameFa?: string; + + @IsOptional() + @IsString() + @MinLength(2) + name?: string; + + @IsOptional() + @IsString() + about?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase letters, numbers, and hyphens', + }) + slug?: string; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @Type(() => Number) + @IsInt({ each: true }) + categoryIds?: number[]; + + @IsOptional() + @ValidateIf((_, value) => value !== null) + @Type(() => Number) + @IsInt() + ownerUserId?: number | null; +} diff --git a/src/business-admin/dto/update-domain.dto.ts b/src/business-admin/dto/update-domain.dto.ts new file mode 100644 index 0000000..71515bd --- /dev/null +++ b/src/business-admin/dto/update-domain.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class UpdateDomainDto { + @IsString() + @MinLength(1) + host!: string; +} diff --git a/src/business-profile/business-profile.controller.ts b/src/business-profile/business-profile.controller.ts new file mode 100644 index 0000000..7f280e9 --- /dev/null +++ b/src/business-profile/business-profile.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BusinessProfileService } from './business-profile.service'; +import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto'; + +@Controller('businesses/:businessId/profile') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class BusinessProfileController { + constructor(private readonly service: BusinessProfileService) {} + + @Get() + @RequireBusinessPermission('business.read') + get(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.get(businessId, user); + } + + @Patch() + @RequireBusinessPermission('business.update') + update( + @Param('businessId') businessId: string, + @Body() dto: UpdateBusinessProfileDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, dto, user); + } +} diff --git a/src/business-profile/business-profile.module.ts b/src/business-profile/business-profile.module.ts new file mode 100644 index 0000000..e366787 --- /dev/null +++ b/src/business-profile/business-profile.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessProfileController } from './business-profile.controller'; +import { BusinessProfileService } from './business-profile.service'; + +@Module({ + imports: [AuthModule], + controllers: [BusinessProfileController], + providers: [BusinessProfileService], +}) +export class BusinessProfileModule {} diff --git a/src/business-profile/business-profile.service.ts b/src/business-profile/business-profile.service.ts new file mode 100644 index 0000000..5441191 --- /dev/null +++ b/src/business-profile/business-profile.service.ts @@ -0,0 +1,510 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { MediaType, Prisma } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import sharp from 'sharp'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { StorageService } from '../storage/storage.service'; +import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto'; +import { + BusinessAddress, + BusinessProfile, + DEFAULT_BUSINESS_SOCIAL_MEDIA, +} from './business-profile.types'; +import { + normalizeEmails, + normalizePhoneNumbers, + normalizeSocialMedia, + toPrismaJsonEmails, + toPrismaJsonPhoneNumbers, + toPrismaJsonSocialMedia, +} from './business-profile.util'; + +const FAVICON_SIZE = 64; +const FAVICON_RADIUS = 14; +const FAVICON_PADDING = 8; + +function roundedRectSvg(size: number, radius: number) { + return Buffer.from( + ` + + `, + ); +} + +function roundedMaskSvg(size: number, radius: number) { + return Buffer.from( + ` + + `, + ); +} + +@Injectable() +export class BusinessProfileService { + private readonly logger = new Logger(BusinessProfileService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly storage: StorageService, + ) {} + + async get(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.read'); + + let business = await this.prisma.business.findUnique({ + where: { id: businessId }, + include: { + categoryAssignments: true, + addresses: { orderBy: { createdAt: 'asc' } }, + logoMedia: true, + faviconMedia: true, + }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + // Backfill or upgrade favicon when logo exists but favicon is missing / outdated. + if (business.logoMediaId) { + const faviconMeta = business.faviconMedia?.metadata as + | { faviconStyle?: string } + | null + | undefined; + const needsFavicon = + !business.faviconMediaId || faviconMeta?.faviconStyle !== 'rounded-v1'; + + if (needsFavicon) { + try { + await this.syncFaviconFromLogo( + businessId, + business.logoMediaId, + actor.id, + business.faviconMediaId, + ); + business = await this.prisma.business.findUnique({ + where: { id: businessId }, + include: { + categoryAssignments: true, + addresses: { orderBy: { createdAt: 'asc' } }, + logoMedia: true, + faviconMedia: true, + }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + } catch (error) { + this.logger.warn( + `Favicon sync failed for business ${businessIdRaw}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + if (!business) { + throw new NotFoundException('Business not found'); + } + + return { + businessId: business.id.toString(), + profile: this.serializeProfile(business), + addresses: business.addresses.map((item) => this.serializeAddress(item)), + }; + } + + async update( + businessIdRaw: string, + dto: UpdateBusinessProfileDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.update'); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + include: { + categoryAssignments: true, + addresses: true, + logoMedia: true, + faviconMedia: true, + }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + if (dto.categoryIds) { + await this.validateCategoryIds(dto.categoryIds); + } + + if (dto.logoMediaId !== undefined && dto.logoMediaId !== null) { + await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId)); + } + + const previousFaviconMediaId = business.faviconMediaId; + const logoChanged = + dto.logoMediaId !== undefined && + (dto.logoMediaId === null + ? business.logoMediaId !== null + : business.logoMediaId?.toString() !== String(dto.logoMediaId)); + + await this.prisma.$transaction(async (tx) => { + const data: Prisma.BusinessUpdateInput = {}; + + if (dto.nameEn !== undefined) data.name = dto.nameEn.trim(); + if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim(); + if (dto.about !== undefined) data.about = dto.about.trim() || null; + if (dto.vision !== undefined) data.vision = dto.vision.trim() || null; + if (dto.emails !== undefined) { + data.emails = toPrismaJsonEmails( + dto.emails.map((item) => item.trim()).filter(Boolean), + ); + } + if (dto.phoneNumbers !== undefined) { + data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers); + } + if (dto.socialMedia !== undefined) { + data.socialMedia = toPrismaJsonSocialMedia({ + ...DEFAULT_BUSINESS_SOCIAL_MEDIA, + ...normalizeSocialMedia(business.socialMedia), + ...dto.socialMedia, + }); + } + if (dto.logoMediaId !== undefined) { + data.logoMedia = + dto.logoMediaId === null + ? { disconnect: true } + : { connect: { id: BigInt(dto.logoMediaId) } }; + + if (dto.logoMediaId === null) { + data.faviconMedia = { disconnect: true }; + } + } + + if (Object.keys(data).length > 0) { + await tx.business.update({ + where: { id: businessId }, + data, + }); + } + + if (dto.categoryIds) { + await tx.businessCategoryAssignment.deleteMany({ + where: { businessId }, + }); + await tx.businessCategoryAssignment.createMany({ + data: dto.categoryIds.map((id) => ({ + businessId, + categoryId: BigInt(id), + })), + }); + } + + if (dto.addresses) { + await this.syncAddresses(tx, businessId, dto.addresses); + } + }); + + if (logoChanged) { + if (dto.logoMediaId === null) { + await this.deleteFaviconMedia(previousFaviconMediaId); + } else if (dto.logoMediaId != null) { + try { + await this.syncFaviconFromLogo( + businessId, + BigInt(dto.logoMediaId), + actor.id, + previousFaviconMediaId, + ); + } catch (error) { + this.logger.warn( + `Failed to generate favicon for business ${businessIdRaw}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + return this.get(businessIdRaw, actor); + } + + private async syncFaviconFromLogo( + businessId: bigint, + logoMediaId: bigint, + uploadedBy: bigint, + previousFaviconMediaId: bigint | null, + ) { + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + select: { slug: true }, + }); + if (!business) return; + + const logo = await this.prisma.media.findFirst({ + where: { id: logoMediaId, businessId }, + }); + if (!logo) { + throw new BadRequestException('Logo media not found for this business'); + } + + const sourceBuffer = await this.storage.getBuffer( + logo.storagePath, + logo.storageDisk, + ); + + const innerSize = FAVICON_SIZE - FAVICON_PADDING * 2; + const logoLayer = await sharp(sourceBuffer) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 255, g: 255, b: 255, alpha: 0 }, + }) + .png() + .toBuffer(); + + // White rounded card + centered logo, then clip to rounded alpha + // so the tab icon shows soft corners (ChatGPT-style). + const composed = await sharp(roundedRectSvg(FAVICON_SIZE, FAVICON_RADIUS)) + .composite([ + { + input: logoLayer, + top: FAVICON_PADDING, + left: FAVICON_PADDING, + }, + ]) + .png() + .toBuffer(); + + const faviconBuffer = await sharp(composed) + .composite([ + { + input: await sharp(roundedMaskSvg(FAVICON_SIZE, FAVICON_RADIUS)) + .png() + .toBuffer(), + blend: 'dest-in', + }, + ]) + .png() + .toBuffer(); + + const fileName = `${randomUUID()}-favicon.png`; + const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`; + const stored = await this.storage.upload({ + key: storageKey, + body: faviconBuffer, + contentType: 'image/png', + }); + + const favicon = await this.prisma.media.create({ + data: { + businessId, + uploadedBy, + mediaType: MediaType.image, + storageDisk: stored.storageDisk, + storagePath: stored.storagePath, + publicUrl: stored.publicUrl, + fileName, + originalFileName: 'favicon.png', + mimeType: 'image/png', + fileSizeBytes: BigInt(faviconBuffer.length), + width: FAVICON_SIZE, + height: FAVICON_SIZE, + altText: 'Business favicon', + metadata: { + derivedFrom: 'logo', + sourceMediaId: logoMediaId.toString(), + purpose: 'favicon', + faviconStyle: 'rounded-v1', + }, + }, + }); + + await this.prisma.business.update({ + where: { id: businessId }, + data: { faviconMediaId: favicon.id }, + }); + + if ( + previousFaviconMediaId && + previousFaviconMediaId.toString() !== favicon.id.toString() + ) { + await this.deleteFaviconMedia(previousFaviconMediaId); + } + } + + private async deleteFaviconMedia(faviconMediaId: bigint | null) { + if (!faviconMediaId) return; + + const media = await this.prisma.media.findUnique({ + where: { id: faviconMediaId }, + }); + if (!media) return; + + await this.prisma.media.delete({ where: { id: faviconMediaId } }).catch(() => { + // already removed or still referenced + }); + + try { + await this.storage.delete(media.storagePath, media.storageDisk); + } catch { + // orphaned object can be cleaned later + } + } + + private serializeProfile(business: { + name: string; + nameFa: string | null; + about: string | null; + vision: string | null; + emails: unknown; + phoneNumbers: unknown; + socialMedia: unknown; + logoMediaId: bigint | null; + logoMedia: { publicUrl: string } | null; + faviconMediaId: bigint | null; + faviconMedia: { publicUrl: string } | null; + categoryAssignments: { categoryId: bigint }[]; + }): BusinessProfile { + return { + nameEn: business.name, + nameFa: business.nameFa ?? '', + about: business.about ?? '', + vision: business.vision ?? '', + emails: normalizeEmails(business.emails), + phoneNumbers: normalizePhoneNumbers(business.phoneNumbers), + socialMedia: normalizeSocialMedia(business.socialMedia), + logoMediaId: business.logoMediaId?.toString() ?? null, + logoUrl: business.logoMedia?.publicUrl ?? null, + faviconMediaId: business.faviconMediaId?.toString() ?? null, + faviconUrl: + business.faviconMedia?.publicUrl ?? + business.logoMedia?.publicUrl ?? + null, + categoryIds: business.categoryAssignments.map((item) => + item.categoryId.toString(), + ), + }; + } + + private serializeAddress(address: { + id: bigint; + province: string; + city: string; + address: string; + postalCode: string | null; + landline: string | null; + }): BusinessAddress { + return { + id: address.id.toString(), + province: address.province, + city: address.city, + address: address.address, + postalCode: address.postalCode, + landline: address.landline, + }; + } + + private async syncAddresses( + tx: Prisma.TransactionClient, + businessId: bigint, + addresses: UpdateBusinessProfileDto['addresses'], + ) { + if (!addresses) return; + + const existing = await tx.address.findMany({ + where: { businessId }, + select: { id: true }, + }); + const existingIds = new Set(existing.map((item) => item.id.toString())); + const keepIds = new Set(); + + for (const item of addresses) { + const payload = { + province: item.province.trim(), + city: item.city.trim(), + address: item.address.trim(), + postalCode: item.postalCode.trim(), + landline: item.landline?.trim() || null, + }; + + if (item.id && existingIds.has(item.id)) { + keepIds.add(item.id); + await tx.address.update({ + where: { id: BigInt(item.id) }, + data: payload, + }); + } else { + await tx.address.create({ + data: { + businessId, + ...payload, + }, + }); + } + } + + const removeIds = [...existingIds].filter((id) => !keepIds.has(id)); + if (removeIds.length > 0) { + await tx.address.deleteMany({ + where: { + businessId, + id: { in: removeIds.map((id) => BigInt(id)) }, + }, + }); + } + } + + private async validateCategoryIds(categoryIds: number[]) { + const found = await this.prisma.businessCategory.count({ + where: { + id: { in: categoryIds.map((id) => BigInt(id)) }, + isActive: true, + }, + }); + + if (found !== categoryIds.length) { + throw new BadRequestException('One or more activity categories are invalid'); + } + } + + private async assertLogoMedia(businessId: bigint, mediaId: bigint) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + select: { id: true }, + }); + + if (!media) { + throw new BadRequestException('Logo media not found for this business'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } +} diff --git a/src/business-profile/business-profile.types.ts b/src/business-profile/business-profile.types.ts new file mode 100644 index 0000000..dcf8687 --- /dev/null +++ b/src/business-profile/business-profile.types.ts @@ -0,0 +1,48 @@ +export type BusinessPhoneType = 'landline' | 'cell'; + +export type BusinessPhoneNumber = { + type: BusinessPhoneType; + number: string; +}; + +export type BusinessSocialMedia = { + whatsapp: string; + telegram: string; + instagram: string; + linkedin: string; + youtube: string; + aparat: string; +}; + +export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = { + whatsapp: '', + telegram: '', + instagram: '', + linkedin: '', + youtube: '', + aparat: '', +}; + +export type BusinessProfile = { + nameEn: string; + nameFa: string; + about: string; + vision: string; + emails: string[]; + phoneNumbers: BusinessPhoneNumber[]; + socialMedia: BusinessSocialMedia; + logoMediaId: string | null; + logoUrl: string | null; + faviconMediaId: string | null; + faviconUrl: string | null; + categoryIds: string[]; +}; + +export type BusinessAddress = { + id: string; + province: string; + city: string; + address: string; + postalCode: string | null; + landline: string | null; +}; diff --git a/src/business-profile/business-profile.util.ts b/src/business-profile/business-profile.util.ts new file mode 100644 index 0000000..b5c6a1a --- /dev/null +++ b/src/business-profile/business-profile.util.ts @@ -0,0 +1,58 @@ +import { + BusinessPhoneNumber, + BusinessSocialMedia, + DEFAULT_BUSINESS_SOCIAL_MEDIA, +} from './business-profile.types'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown, fallback = '') { + return typeof value === 'string' ? value : fallback; +} + +export function normalizeEmails(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter(isRecord) + .map((item) => ({ + type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'], + number: readString(item.number).trim(), + })) + .filter((item) => item.number.length > 0); +} + +export function normalizeSocialMedia(raw: unknown): BusinessSocialMedia { + const source = isRecord(raw) ? raw : {}; + + return { + whatsapp: readString(source.whatsapp), + telegram: readString(source.telegram), + instagram: readString(source.instagram), + linkedin: readString(source.linkedin), + youtube: readString(source.youtube), + aparat: readString(source.aparat), + }; +} + +export function toPrismaJsonEmails(emails: string[]) { + return emails; +} + +export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) { + return phoneNumbers; +} + +export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) { + return socialMedia ?? DEFAULT_BUSINESS_SOCIAL_MEDIA; +} diff --git a/src/business-profile/dto/update-business-profile.dto.ts b/src/business-profile/dto/update-business-profile.dto.ts new file mode 100644 index 0000000..add749f --- /dev/null +++ b/src/business-profile/dto/update-business-profile.dto.ts @@ -0,0 +1,124 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsIn, + IsInt, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +class BusinessPhoneNumberDto { + @IsIn(['landline', 'cell']) + type!: 'landline' | 'cell'; + + @IsString() + @MinLength(3) + number!: string; +} + +class BusinessSocialMediaDto { + @IsOptional() + @IsString() + whatsapp?: string; + + @IsOptional() + @IsString() + telegram?: string; + + @IsOptional() + @IsString() + instagram?: string; + + @IsOptional() + @IsString() + linkedin?: string; + + @IsOptional() + @IsString() + youtube?: string; + + @IsOptional() + @IsString() + aparat?: string; +} + +class BusinessAddressDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @MinLength(1) + province!: string; + + @IsString() + @MinLength(1) + city!: string; + + @IsString() + @MinLength(1) + address!: string; + + @IsString() + @MinLength(1) + postalCode!: string; + + @IsOptional() + @IsString() + landline?: string | null; +} + +export class UpdateBusinessProfileDto { + @IsOptional() + @IsString() + @MinLength(2) + nameEn?: string; + + @IsOptional() + @IsString() + @MinLength(2) + nameFa?: string; + + @IsOptional() + @IsString() + about?: string; + + @IsOptional() + @IsString() + vision?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + emails?: string[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BusinessPhoneNumberDto) + phoneNumbers?: BusinessPhoneNumberDto[]; + + @IsOptional() + @ValidateNested() + @Type(() => BusinessSocialMediaDto) + socialMedia?: BusinessSocialMediaDto; + + @IsOptional() + @Type(() => Number) + @IsInt() + logoMediaId?: number | null; + + @IsOptional() + @IsArray() + @Type(() => Number) + @IsInt({ each: true }) + categoryIds?: number[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BusinessAddressDto) + addresses?: BusinessAddressDto[]; +} diff --git a/src/business-settings/business-primary-colors.ts b/src/business-settings/business-primary-colors.ts new file mode 100644 index 0000000..6639681 --- /dev/null +++ b/src/business-settings/business-primary-colors.ts @@ -0,0 +1,98 @@ +export const BUSINESS_PRIMARY_COLOR_IDS = [ + 'red', + 'yellow', + 'black', + 'cyan', + 'purple', + 'light-blue', + 'dark-blue', +] as const; + +export type BusinessPrimaryColorId = (typeof BUSINESS_PRIMARY_COLOR_IDS)[number]; + +export const DEFAULT_BUSINESS_PRIMARY_COLOR_ID: BusinessPrimaryColorId = + 'dark-blue'; + +export type BusinessPrimaryColorTokens = { + label: string; + primary: string; + primaryDark: string; + primaryLight: string; + primaryGlow: string; + primaryRgb: string; +}; + +export const BUSINESS_PRIMARY_COLOR_PALETTE: Record< + BusinessPrimaryColorId, + BusinessPrimaryColorTokens +> = { + red: { + label: 'Red', + primary: '#ef4444', + primaryDark: '#dc2626', + primaryLight: '#fee2e2', + primaryGlow: '#ef4444', + primaryRgb: '239 68 68', + }, + yellow: { + label: 'Yellow', + primary: '#eab308', + primaryDark: '#ca8a04', + primaryLight: '#fef9c3', + primaryGlow: '#eab308', + primaryRgb: '234 179 8', + }, + black: { + label: 'Black', + primary: '#1e293b', + primaryDark: '#0f172a', + primaryLight: '#e2e8f0', + primaryGlow: '#334155', + primaryRgb: '30 41 59', + }, + cyan: { + label: 'Cyan', + primary: '#06b6d4', + primaryDark: '#0891b2', + primaryLight: '#cffafe', + primaryGlow: '#06b6d4', + primaryRgb: '6 182 212', + }, + purple: { + label: 'Purple', + primary: '#a855f7', + primaryDark: '#9333ea', + primaryLight: '#f3e8ff', + primaryGlow: '#a855f7', + primaryRgb: '168 85 247', + }, + 'light-blue': { + label: 'Light Blue', + primary: '#38bdf8', + primaryDark: '#0ea5e9', + primaryLight: '#e0f2fe', + primaryGlow: '#38bdf8', + primaryRgb: '56 189 248', + }, + 'dark-blue': { + label: 'Dark Blue', + primary: '#3b82f6', + primaryDark: '#2563eb', + primaryLight: '#dbeafe', + primaryGlow: '#3b82f6', + primaryRgb: '59 130 246', + }, +}; + +export function normalizeBusinessPrimaryColorId( + value: unknown, +): BusinessPrimaryColorId { + if ( + typeof value === 'string' && + BUSINESS_PRIMARY_COLOR_IDS.includes(value as BusinessPrimaryColorId) + ) { + return value as BusinessPrimaryColorId; + } + + return DEFAULT_BUSINESS_PRIMARY_COLOR_ID; +} diff --git a/src/business-settings/business-settings.controller.ts b/src/business-settings/business-settings.controller.ts new file mode 100644 index 0000000..23ce42b --- /dev/null +++ b/src/business-settings/business-settings.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BusinessSettingsService } from './business-settings.service'; +import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto'; + +@Controller('businesses/:businessId/settings') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class BusinessSettingsController { + constructor(private readonly service: BusinessSettingsService) {} + + @Get() + @RequireBusinessPermission('business.read') + get(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.get(businessId, user); + } + + @Patch() + @RequireBusinessPermission('business.update') + update( + @Param('businessId') businessId: string, + @Body() dto: UpdateBusinessSettingsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, dto, user); + } +} diff --git a/src/business-settings/business-settings.module.ts b/src/business-settings/business-settings.module.ts new file mode 100644 index 0000000..2a37f97 --- /dev/null +++ b/src/business-settings/business-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsController } from './business-settings.controller'; +import { BusinessSettingsService } from './business-settings.service'; + +@Module({ + imports: [AuthModule], + controllers: [BusinessSettingsController], + providers: [BusinessSettingsService], + exports: [BusinessSettingsService], +}) +export class BusinessSettingsModule {} diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts new file mode 100644 index 0000000..00b8a56 --- /dev/null +++ b/src/business-settings/business-settings.service.ts @@ -0,0 +1,158 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { BusinessSettings } from './business-settings.types'; +import { + mergeBusinessSettings, + normalizeBusinessSettings, + toPrismaJson, +} from './business-settings.util'; +import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto'; +import { normalizeBusinessPrimaryColorId } from './business-primary-colors'; + +@Injectable() +export class BusinessSettingsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async get(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.read'); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + select: { id: true, settings: true }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + return { + businessId: business.id.toString(), + settings: normalizeBusinessSettings(business.settings), + }; + } + + async update( + businessIdRaw: string, + dto: UpdateBusinessSettingsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.update'); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + select: { id: true, settings: true }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + const current = normalizeBusinessSettings(business.settings); + const patch: Partial = {}; + + if (dto.branding) { + patch.branding = { + primaryColor: normalizeBusinessPrimaryColorId( + dto.branding.primaryColor ?? current.branding.primaryColor, + ), + }; + } + + if (dto.dashboard) { + patch.dashboard = { + comments: { + autoApprove: + dto.dashboard.comments?.autoApprove ?? + current.dashboard.comments.autoApprove, + }, + expertReviews: { + autoApprove: + dto.dashboard.expertReviews?.autoApprove ?? + current.dashboard.expertReviews.autoApprove, + }, + }; + } + + if (dto.store) { + patch.store = { + onlineSellEnabled: + dto.store.onlineSellEnabled ?? current.store.onlineSellEnabled, + orderProcessSteps: + dto.store.orderProcessSteps ?? current.store.orderProcessSteps, + }; + } + + const next = mergeBusinessSettings(current, patch); + + const updated = await this.prisma.business.update({ + where: { id: businessId }, + data: { settings: toPrismaJson(next) }, + select: { id: true }, + }); + + return { + businessId: updated.id.toString(), + settings: next, + }; + } + + async getNormalizedSettings(businessId: bigint): Promise { + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + select: { settings: true }, + }); + + if (!business) { + throw new NotFoundException('Business not found'); + } + + return normalizeBusinessSettings(business.settings); + } + + async isCommentsAutoApprove(businessId: bigint) { + const settings = await this.getNormalizedSettings(businessId); + return settings.dashboard.comments.autoApprove; + } + + async isExpertReviewsAutoApprove(businessId: bigint) { + const settings = await this.getNormalizedSettings(businessId); + return settings.dashboard.expertReviews.autoApprove; + } + + async isOnlineSellEnabled(businessId: bigint) { + const settings = await this.getNormalizedSettings(businessId); + return settings.store.onlineSellEnabled; + } + + async getOrderProcessSteps(businessId: bigint) { + const settings = await this.getNormalizedSettings(businessId); + return settings.store.orderProcessSteps; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } +} diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts new file mode 100644 index 0000000..c6a7609 --- /dev/null +++ b/src/business-settings/business-settings.types.ts @@ -0,0 +1,60 @@ +import type { BusinessPrimaryColorId } from './business-primary-colors'; +import { DEFAULT_BUSINESS_PRIMARY_COLOR_ID } from './business-primary-colors'; + +export type BrandingSettings = { + primaryColor: BusinessPrimaryColorId; +}; + +export type DashboardCommentsSettings = { + autoApprove: boolean; +}; + +export type DashboardExpertReviewsSettings = { + autoApprove: boolean; +}; + +/** Per-business dashboard settings. Add new sections here as the CMS grows. */ +export type DashboardSettings = { + comments: DashboardCommentsSettings; + expertReviews: DashboardExpertReviewsSettings; +}; + +export type OrderProcessStep = { + id: string; + label: string; + color: string; +}; + +/** Per-business store / sales settings. */ +export type StoreSettings = { + onlineSellEnabled: boolean; + orderProcessSteps: OrderProcessStep[]; +}; + +/** Top-level business settings stored in `businesses.settings` JSONB. */ +export type BusinessSettings = { + branding: BrandingSettings; + dashboard: DashboardSettings; + store: StoreSettings; +}; + +export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [ + { id: 'processing', label: 'Under processing', color: '#3B82F6' }, + { id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' }, + { id: 'shipped', label: 'Shipped', color: '#8B5CF6' }, + { id: 'delivered', label: 'Delivered', color: '#22C55E' }, +]; + +export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = { + branding: { + primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID, + }, + dashboard: { + comments: { autoApprove: false }, + expertReviews: { autoApprove: false }, + }, + store: { + onlineSellEnabled: true, + orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS, + }, +}; diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts new file mode 100644 index 0000000..7327e28 --- /dev/null +++ b/src/business-settings/business-settings.util.ts @@ -0,0 +1,119 @@ +import { Prisma } from '@prisma/client'; +import { + normalizeBusinessPrimaryColorId, +} from './business-primary-colors'; +import { + BusinessSettings, + DEFAULT_BUSINESS_SETTINGS, + DEFAULT_ORDER_PROCESS_STEPS, + OrderProcessStep, +} from './business-settings.types'; +import { + defaultOrderStepColor, + normalizeOrderStepColor, +} from './order-step-colors'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown, fallback: boolean) { + return typeof value === 'boolean' ? value : fallback; +} + +function readOrderProcessSteps(value: unknown): OrderProcessStep[] { + if (!Array.isArray(value)) { + return DEFAULT_ORDER_PROCESS_STEPS; + } + + const steps = value + .map((item, index) => { + if (!isRecord(item)) return null; + const id = typeof item.id === 'string' ? item.id.trim() : ''; + const label = typeof item.label === 'string' ? item.label.trim() : ''; + if (!id || !label) return null; + return { + id, + label, + color: normalizeOrderStepColor( + item.color, + defaultOrderStepColor(id, index), + ), + }; + }) + .filter((step): step is OrderProcessStep => step !== null); + + return steps.length ? steps : DEFAULT_ORDER_PROCESS_STEPS; +} + +export function normalizeBusinessSettings(raw: unknown): BusinessSettings { + const source = isRecord(raw) ? raw : {}; + const branding = isRecord(source.branding) ? source.branding : {}; + const dashboard = isRecord(source.dashboard) ? source.dashboard : {}; + const comments = isRecord(dashboard.comments) ? dashboard.comments : {}; + const expertReviews = isRecord(dashboard.expertReviews) + ? dashboard.expertReviews + : {}; + const store = isRecord(source.store) ? source.store : {}; + + return { + branding: { + primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor), + }, + dashboard: { + comments: { + autoApprove: readBoolean( + comments.autoApprove, + DEFAULT_BUSINESS_SETTINGS.dashboard.comments.autoApprove, + ), + }, + expertReviews: { + autoApprove: readBoolean( + expertReviews.autoApprove, + DEFAULT_BUSINESS_SETTINGS.dashboard.expertReviews.autoApprove, + ), + }, + }, + store: { + onlineSellEnabled: readBoolean( + store.onlineSellEnabled, + DEFAULT_BUSINESS_SETTINGS.store.onlineSellEnabled, + ), + orderProcessSteps: readOrderProcessSteps(store.orderProcessSteps), + }, + }; +} + +export function mergeBusinessSettings( + current: BusinessSettings, + patch: Partial, +): BusinessSettings { + return { + branding: { + primaryColor: + patch.branding?.primaryColor ?? current.branding.primaryColor, + }, + dashboard: { + comments: { + autoApprove: + patch.dashboard?.comments?.autoApprove ?? + current.dashboard.comments.autoApprove, + }, + expertReviews: { + autoApprove: + patch.dashboard?.expertReviews?.autoApprove ?? + current.dashboard.expertReviews.autoApprove, + }, + }, + store: { + onlineSellEnabled: + patch.store?.onlineSellEnabled ?? current.store.onlineSellEnabled, + orderProcessSteps: + patch.store?.orderProcessSteps ?? current.store.orderProcessSteps, + }, + }; +} + +export function toPrismaJson(settings: BusinessSettings): Prisma.InputJsonValue { + return settings as Prisma.InputJsonValue; +} diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts new file mode 100644 index 0000000..c8c4ac2 --- /dev/null +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -0,0 +1,86 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsIn, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { ORDER_STEP_COLOR_HEXES } from '../order-step-colors'; +import { BUSINESS_PRIMARY_COLOR_IDS } from '../business-primary-colors'; + +class BrandingSettingsDto { + @IsOptional() + @IsString() + @IsIn([...BUSINESS_PRIMARY_COLOR_IDS]) + primaryColor?: string; +} + +class DashboardCommentsSettingsDto { + @IsOptional() + @IsBoolean() + autoApprove?: boolean; +} + +class DashboardExpertReviewsSettingsDto { + @IsOptional() + @IsBoolean() + autoApprove?: boolean; +} + +class DashboardSettingsDto { + @IsOptional() + @ValidateNested() + @Type(() => DashboardCommentsSettingsDto) + comments?: DashboardCommentsSettingsDto; + + @IsOptional() + @ValidateNested() + @Type(() => DashboardExpertReviewsSettingsDto) + expertReviews?: DashboardExpertReviewsSettingsDto; +} + +class OrderProcessStepDto { + @IsString() + @MinLength(1) + id!: string; + + @IsString() + @MinLength(1) + label!: string; + + @IsString() + @IsIn([...ORDER_STEP_COLOR_HEXES]) + color!: string; +} + +class StoreSettingsDto { + @IsOptional() + @IsBoolean() + onlineSellEnabled?: boolean; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderProcessStepDto) + orderProcessSteps?: OrderProcessStepDto[]; +} + +export class UpdateBusinessSettingsDto { + @IsOptional() + @ValidateNested() + @Type(() => BrandingSettingsDto) + branding?: BrandingSettingsDto; + + @IsOptional() + @ValidateNested() + @Type(() => DashboardSettingsDto) + dashboard?: DashboardSettingsDto; + + @IsOptional() + @ValidateNested() + @Type(() => StoreSettingsDto) + store?: StoreSettingsDto; +} diff --git a/src/business-settings/order-step-colors.ts b/src/business-settings/order-step-colors.ts new file mode 100644 index 0000000..0cbafd4 --- /dev/null +++ b/src/business-settings/order-step-colors.ts @@ -0,0 +1,56 @@ +export const ORDER_STEP_COLOR_HEXES = [ + '#EF4444', + '#F97316', + '#F59E0B', + '#EAB308', + '#84CC16', + '#22C55E', + '#10B981', + '#14B8A6', + '#06B6D4', + '#0EA5E9', + '#3B82F6', + '#6366F1', + '#8B5CF6', + '#A855F7', + '#D946EF', + '#EC4899', + '#F43F5E', + '#78716C', + '#6B7280', + '#64748B', + '#111827', + '#92400E', + '#1E3A5F', + '#D4AF37', +] as const; + +export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6'; + +const DEFAULT_STEP_COLORS_BY_ID: Record = { + processing: '#3B82F6', + 'ready-for-shipping': '#F59E0B', + shipped: '#8B5CF6', + delivered: '#22C55E', +}; + +export function isOrderStepColor(value: string): boolean { + return ORDER_STEP_COLOR_HEXES.includes(value as (typeof ORDER_STEP_COLOR_HEXES)[number]); +} + +export function normalizeOrderStepColor( + value: unknown, + fallback = DEFAULT_ORDER_STEP_COLOR, +): string { + if (typeof value === 'string' && isOrderStepColor(value)) { + return value; + } + return fallback; +} + +export function defaultOrderStepColor(id: string, index = 0): string { + return ( + DEFAULT_STEP_COLORS_BY_ID[id] ?? + ORDER_STEP_COLOR_HEXES[index % ORDER_STEP_COLOR_HEXES.length] + ); +} diff --git a/src/business-team/business-team.controller.ts b/src/business-team/business-team.controller.ts new file mode 100644 index 0000000..76c3e61 --- /dev/null +++ b/src/business-team/business-team.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { BusinessTeamService } from './business-team.service'; +import { AddTeamMemberDto } from './dto/add-team-member.dto'; +import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; + +@Controller('businesses/:businessId/team') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class BusinessTeamController { + constructor(private readonly teamService: BusinessTeamService) {} + + @Get() + @RequireBusinessPermission('business.team.read') + list(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.teamService.list(BigInt(businessId), user); + } + + @Post() + @RequireBusinessPermission('business.team.invite') + add( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + @Body() dto: AddTeamMemberDto, + ) { + return this.teamService.add(BigInt(businessId), user, dto); + } + + @Patch(':memberId') + @RequireBusinessPermission('business.team.update') + update( + @Param('businessId') businessId: string, + @Param('memberId') memberId: string, + @CurrentUser() user: AuthUser, + @Body() dto: UpdateTeamMemberDto, + ) { + return this.teamService.update( + BigInt(businessId), + BigInt(memberId), + user, + dto, + ); + } + + @Delete(':memberId') + @RequireBusinessPermission('business.team.remove') + remove( + @Param('businessId') businessId: string, + @Param('memberId') memberId: string, + @CurrentUser() user: AuthUser, + ) { + return this.teamService.remove( + BigInt(businessId), + BigInt(memberId), + user, + ); + } +} diff --git a/src/business-team/business-team.module.ts b/src/business-team/business-team.module.ts new file mode 100644 index 0000000..21314f3 --- /dev/null +++ b/src/business-team/business-team.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessTeamController } from './business-team.controller'; +import { BusinessTeamService } from './business-team.service'; + +@Module({ + imports: [AuthModule], + controllers: [BusinessTeamController], + providers: [BusinessTeamService], +}) +export class BusinessTeamModule {} diff --git a/src/business-team/business-team.service.ts b/src/business-team/business-team.service.ts new file mode 100644 index 0000000..e855f5c --- /dev/null +++ b/src/business-team/business-team.service.ts @@ -0,0 +1,284 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { + ASSIGNABLE_TEAM_ROLES, + AssignableTeamRole, + AuthUser, +} from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AddTeamMemberDto } from './dto/add-team-member.dto'; +import { UpdateTeamMemberDto } from './dto/update-team-member.dto'; + +@Injectable() +export class BusinessTeamService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list(businessId: bigint, actor: AuthUser) { + await this.ensureCanRead(businessId, actor.id); + + const members = await this.prisma.businessUser.findMany({ + where: { businessId }, + include: { + user: true, + role: true, + inviter: { select: { id: true, firstName: true, lastName: true } }, + }, + orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }], + }); + + return { + members: await Promise.all( + members.map(async (m) => ({ + id: m.id, + userId: m.user.id, + cellNumber: m.user.cellNumber, + firstName: m.user.firstName, + lastName: m.user.lastName, + email: m.user.email, + isOwner: m.isOwner, + teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null, + permissions: await this.permissions.getPermissionsForBusiness( + m.user.id, + businessId, + ), + invitedBy: m.inviter, + createdAt: m.createdAt, + })), + ), + }; + } + + async add(businessId: bigint, actor: AuthUser, dto: AddTeamMemberDto) { + const canInvite = await this.permissions.hasBusinessPermission( + actor.id, + businessId, + 'business.team.invite', + ); + if (!canInvite) { + throw new ForbiddenException('You cannot invite team members'); + } + + this.assertAssignableRole(dto.roleSlug); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + if (!business?.isActive) { + throw new NotFoundException('Business not found'); + } + + const staffRole = await this.prisma.role.findUnique({ + where: { slug: dto.roleSlug }, + }); + if (!staffRole) { + throw new BadRequestException('Invalid team role'); + } + + const businessStaffRole = await this.prisma.role.findUnique({ + where: { slug: 'business_staff' }, + }); + if (!businessStaffRole) { + throw new Error('business_staff role is missing. Run migrations.'); + } + + const existingUser = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + }); + + if (existingUser) { + const existingMembership = await this.prisma.businessUser.findUnique({ + where: { + businessId_userId: { businessId, userId: existingUser.id }, + }, + }); + if (existingMembership) { + throw new ConflictException('User is already a member of this business'); + } + } + + if (!existingUser && !dto.password) { + throw new BadRequestException('password is required for new users'); + } + + const passwordHash = existingUser + ? existingUser.passwordHash + : await bcrypt.hash(dto.password!, 10); + + const member = await this.prisma.$transaction(async (tx) => { + const user = + existingUser ?? + (await tx.user.create({ + data: { + cellNumber: dto.cellNumber, + passwordHash, + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + cellVerifiedAt: new Date(), + }, + })); + + const businessUser = await tx.businessUser.create({ + data: { + businessId, + userId: user.id, + isOwner: false, + roleId: staffRole.id, + invitedBy: actor.id, + }, + include: { user: true, role: true }, + }); + + const hasStaffGlobalRole = await tx.userRole.findUnique({ + where: { + userId_roleId: { + userId: user.id, + roleId: businessStaffRole.id, + }, + }, + }); + + if (!hasStaffGlobalRole) { + await tx.userRole.create({ + data: { userId: user.id, roleId: businessStaffRole.id }, + }); + } + + return businessUser; + }); + + return { + message: 'Team member added', + member: { + id: member.id, + userId: member.user.id, + cellNumber: member.user.cellNumber, + firstName: member.user.firstName, + lastName: member.user.lastName, + isOwner: false, + teamRole: member.role?.slug ?? dto.roleSlug, + permissions: await this.permissions.getPermissionsForBusiness( + member.user.id, + businessId, + ), + }, + }; + } + + async update( + businessId: bigint, + memberId: bigint, + actor: AuthUser, + dto: UpdateTeamMemberDto, + ) { + const canUpdate = await this.permissions.hasBusinessPermission( + actor.id, + businessId, + 'business.team.update', + ); + if (!canUpdate) { + throw new ForbiddenException('You cannot update team members'); + } + + this.assertAssignableRole(dto.roleSlug); + + const member = await this.prisma.businessUser.findFirst({ + where: { id: memberId, businessId }, + include: { user: true, role: true }, + }); + + if (!member) { + throw new NotFoundException('Team member not found'); + } + + if (member.isOwner) { + throw new ForbiddenException('Cannot change the role of a business owner'); + } + + const staffRole = await this.prisma.role.findUnique({ + where: { slug: dto.roleSlug }, + }); + if (!staffRole) { + throw new BadRequestException('Invalid team role'); + } + + const updated = await this.prisma.businessUser.update({ + where: { id: memberId }, + data: { roleId: staffRole.id }, + include: { user: true, role: true }, + }); + + return { + message: 'Team member role updated', + member: { + id: updated.id, + userId: updated.user.id, + teamRole: updated.role?.slug ?? dto.roleSlug, + permissions: await this.permissions.getPermissionsForBusiness( + updated.user.id, + businessId, + ), + }, + }; + } + + async remove(businessId: bigint, memberId: bigint, actor: AuthUser) { + const canRemove = await this.permissions.hasBusinessPermission( + actor.id, + businessId, + 'business.team.remove', + ); + if (!canRemove) { + throw new ForbiddenException('You cannot remove team members'); + } + + const member = await this.prisma.businessUser.findFirst({ + where: { id: memberId, businessId }, + }); + + if (!member) { + throw new NotFoundException('Team member not found'); + } + + if (member.isOwner) { + throw new ForbiddenException('Cannot remove a business owner'); + } + + if (member.userId === actor.id) { + throw new ForbiddenException('You cannot remove yourself'); + } + + await this.prisma.businessUser.delete({ where: { id: memberId } }); + + return { message: 'Team member removed' }; + } + + private async ensureCanRead(businessId: bigint, userId: bigint) { + const canRead = await this.permissions.hasBusinessPermission( + userId, + businessId, + 'business.team.read', + ); + if (!canRead) { + throw new ForbiddenException('You cannot view this business team'); + } + } + + private assertAssignableRole(roleSlug: string): asserts roleSlug is AssignableTeamRole { + if (!ASSIGNABLE_TEAM_ROLES.includes(roleSlug as AssignableTeamRole)) { + throw new BadRequestException( + `roleSlug must be one of: ${ASSIGNABLE_TEAM_ROLES.join(', ')}`, + ); + } + } +} diff --git a/src/business-team/dto/add-team-member.dto.ts b/src/business-team/dto/add-team-member.dto.ts new file mode 100644 index 0000000..703ad26 --- /dev/null +++ b/src/business-team/dto/add-team-member.dto.ts @@ -0,0 +1,29 @@ +import { IsEmail, IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; +import { ASSIGNABLE_TEAM_ROLES } from '../../auth/auth.types'; + +export class AddTeamMemberDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/) + cellNumber!: string; + + @IsOptional() + @IsString() + @MinLength(8) + password?: string; + + @IsString() + @MinLength(2) + firstName!: string; + + @IsString() + @MinLength(2) + lastName!: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsString() + @IsIn([...ASSIGNABLE_TEAM_ROLES]) + roleSlug!: string; +} diff --git a/src/business-team/dto/update-team-member.dto.ts b/src/business-team/dto/update-team-member.dto.ts new file mode 100644 index 0000000..d6c4f07 --- /dev/null +++ b/src/business-team/dto/update-team-member.dto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsString } from 'class-validator'; +import { ASSIGNABLE_TEAM_ROLES } from '../../auth/auth.types'; + +export class UpdateTeamMemberDto { + @IsString() + @IsIn([...ASSIGNABLE_TEAM_ROLES]) + roleSlug!: string; +} diff --git a/src/cart/cart.controller.ts b/src/cart/cart.controller.ts new file mode 100644 index 0000000..e6c2b94 --- /dev/null +++ b/src/cart/cart.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + AddCartItemDto, + CheckoutCartDto, + UpdateCartItemDto, +} from './dto/cart.dto'; +import { CartService } from './cart.service'; + +@Controller('businesses/:businessId/cart') +@UseGuards(JwtAuthGuard) +export class CartController { + constructor(private readonly service: CartService) {} + + @Get() + getCart(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.getCart(businessId, user); + } + + @Post('items') + addItem( + @Param('businessId') businessId: string, + @Body() dto: AddCartItemDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.addItem(businessId, dto, user); + } + + @Patch('items/:itemId') + updateItem( + @Param('businessId') businessId: string, + @Param('itemId') itemId: string, + @Body() dto: UpdateCartItemDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateItem(businessId, itemId, dto, user); + } + + @Delete('items/:itemId') + removeItem( + @Param('businessId') businessId: string, + @Param('itemId') itemId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.removeItem(businessId, itemId, user); + } + + @Delete() + clear(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) { + return this.service.clear(businessId, user); + } + + @Post('checkout') + checkout( + @Param('businessId') businessId: string, + @Body() dto: CheckoutCartDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.checkout(businessId, dto, user); + } +} diff --git a/src/cart/cart.module.ts b/src/cart/cart.module.ts new file mode 100644 index 0000000..004aab2 --- /dev/null +++ b/src/cart/cart.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OrdersModule } from '../orders/orders.module'; +import { CartController } from './cart.controller'; +import { CartService } from './cart.service'; + +@Module({ + imports: [AuthModule, OrdersModule], + controllers: [CartController], + providers: [CartService], +}) +export class CartModule {} diff --git a/src/cart/cart.service.ts b/src/cart/cart.service.ts new file mode 100644 index 0000000..9a104d1 --- /dev/null +++ b/src/cart/cart.service.ts @@ -0,0 +1,404 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AddCartItemDto, + CheckoutCartDto, + ShippingAddressDto, + UpdateCartItemDto, +} from './dto/cart.dto'; +import { OrdersService } from '../orders/orders.service'; + +const cartVariantInclude = { + storeItem: { + include: { + product: { + include: { + featuredMedia: true, + }, + }, + }, + }, + selections: { + include: { + variation: true, + option: true, + }, + }, +} satisfies Prisma.StoreItemVariantInclude; + +type CartWithItems = Prisma.CartGetPayload<{ + include: { + items: { + include: { + storeItemVariant: { + include: typeof cartVariantInclude; + }; + }; + }; + }; +}>; + +@Injectable() +export class CartService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly orders: OrdersService, + ) {} + + async getCart(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const cart = await this.getOrCreateCart(businessId, actor.id); + return { cart: this.serializeCart(cart) }; + } + + async addItem(businessIdRaw: string, dto: AddCartItemDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const storeItemVariantId = BigInt(dto.storeItemVariantId); + const quantity = dto.quantity ?? 1; + await this.assertCustomerAccess(businessId, actor); + + const variant = await this.loadPurchasableVariant(businessId, storeItemVariantId); + this.assertStockAvailable(variant.stockQuantity, quantity); + + const cart = await this.getOrCreateCart(businessId, actor.id); + const existing = cart.items.find( + (item) => item.storeItemVariantId === storeItemVariantId, + ); + + if (existing) { + const newQuantity = existing.quantity + quantity; + this.assertStockAvailable(variant.stockQuantity, newQuantity); + + await this.prisma.cartItem.update({ + where: { id: existing.id }, + data: { quantity: newQuantity }, + }); + + const refreshed = await this.loadCart(cart.id); + return { + message: 'Cart item quantity updated', + cart: this.serializeCart(refreshed), + }; + } + + await this.prisma.cartItem.create({ + data: { + cartId: cart.id, + storeItemVariantId, + quantity, + }, + }); + + const refreshed = await this.loadCart(cart.id); + return { + message: 'Item added to cart', + cart: this.serializeCart(refreshed), + }; + } + + async updateItem( + businessIdRaw: string, + itemIdRaw: string, + dto: UpdateCartItemDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const itemId = BigInt(itemIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const cart = await this.getOrCreateCart(businessId, actor.id); + const item = cart.items.find((entry) => entry.id === itemId); + + if (!item) { + throw new NotFoundException('Cart item not found'); + } + + this.assertStockAvailable(item.storeItemVariant.stockQuantity, dto.quantity); + + await this.prisma.cartItem.update({ + where: { id: itemId }, + data: { quantity: dto.quantity }, + }); + + const refreshed = await this.loadCart(cart.id); + return { + message: 'Cart item updated', + cart: this.serializeCart(refreshed), + }; + } + + async removeItem(businessIdRaw: string, itemIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const itemId = BigInt(itemIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const cart = await this.getOrCreateCart(businessId, actor.id); + const item = cart.items.find((entry) => entry.id === itemId); + + if (!item) { + throw new NotFoundException('Cart item not found'); + } + + await this.prisma.cartItem.delete({ where: { id: itemId } }); + + const refreshed = await this.loadCart(cart.id); + return { + message: 'Cart item removed', + cart: this.serializeCart(refreshed), + }; + } + + async clear(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const cart = await this.getOrCreateCart(businessId, actor.id); + await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } }); + + const refreshed = await this.loadCart(cart.id); + return { + message: 'Cart cleared', + cart: this.serializeCart(refreshed), + }; + } + + async checkout( + businessIdRaw: string, + dto: CheckoutCartDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const cart = await this.getOrCreateCart(businessId, actor.id); + + if (!cart.items.length) { + throw new BadRequestException('Cart is empty'); + } + + const shippingAddress = await this.resolveShippingAddress( + actor.id, + dto.addressId, + dto.shippingAddress, + ); + + const order = await this.orders.createFromCart({ + businessId, + userId: actor.id, + createdBy: actor.id, + source: 'website', + cartItems: cart.items, + shippingAddress, + addressId: dto.addressId ? BigInt(dto.addressId) : null, + customerNotes: dto.customerNotes?.trim() || null, + adminNotes: null, + status: 'pending', + payment: { + type: dto.payment.type, + posType: dto.payment.posType, + gatewayType: dto.payment.gatewayType, + transferAccount: dto.payment.transferAccount, + transferRefNumber: dto.payment.transferRefNumber, + notes: dto.payment.notes?.trim() || null, + }, + }); + + await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } }); + + return { + message: 'Order placed successfully', + order, + }; + } + + private async getOrCreateCart(businessId: bigint, userId: bigint) { + const existing = await this.prisma.cart.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + }); + + if (existing) { + return this.loadCart(existing.id); + } + + const created = await this.prisma.cart.create({ + data: { businessId, userId }, + }); + + return this.loadCart(created.id); + } + + private async loadCart(cartId: bigint): Promise { + return this.prisma.cart.findUniqueOrThrow({ + where: { id: cartId }, + include: { + items: { + orderBy: { createdAt: 'asc' }, + include: { + storeItemVariant: { + include: cartVariantInclude, + }, + }, + }, + }, + }); + } + + private async loadPurchasableVariant( + businessId: bigint, + storeItemVariantId: bigint, + ) { + const variant = await this.prisma.storeItemVariant.findFirst({ + where: { id: storeItemVariantId, businessId, isActive: true }, + include: { + storeItem: { + include: { + product: true, + }, + }, + }, + }); + + if (!variant) { + throw new NotFoundException('Store item variant not found or unavailable'); + } + + if (variant.storeItem.product.status !== ContentStatus.published) { + throw new BadRequestException('Product is not available for purchase'); + } + + if (variant.price === null) { + throw new BadRequestException('Store item variant has no price configured'); + } + + return variant; + } + + private async resolveShippingAddress( + userId: bigint, + addressIdRaw: string | undefined, + inline: ShippingAddressDto | undefined, + ) { + if (addressIdRaw) { + const address = await this.prisma.address.findFirst({ + where: { id: BigInt(addressIdRaw), userId }, + }); + + if (!address) { + throw new NotFoundException('Shipping address not found'); + } + + return { + province: address.province, + city: address.city, + address: address.address, + postalCode: address.postalCode, + landline: address.landline, + }; + } + + if (!inline) { + throw new BadRequestException( + 'Provide addressId or shippingAddress for checkout', + ); + } + + return { + province: inline.province.trim(), + city: inline.city.trim(), + address: inline.address.trim(), + postalCode: inline.postalCode?.trim() || null, + landline: inline.landline?.trim() || null, + }; + } + + private assertStockAvailable(stockQuantity: number | null, requested: number) { + if (stockQuantity !== null && requested > stockQuantity) { + throw new BadRequestException('Insufficient stock for this quantity'); + } + } + + private serializeCart(cart: CartWithItems) { + const items = cart.items.map((item) => this.serializeCartItem(item)); + const subtotal = items.reduce((sum, item) => sum + item.lineTotal, 0); + + return { + id: cart.id.toString(), + businessId: cart.businessId.toString(), + items, + itemCount: items.reduce((sum, item) => sum + item.quantity, 0), + subtotal, + updatedAt: cart.updatedAt, + }; + } + + private serializeCartItem(item: CartWithItems['items'][number]) { + const variant = item.storeItemVariant; + const product = variant.storeItem.product; + const content = this.asRecord(product.content); + const price = Number(variant.price); + const compareAtPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + const effectivePrice = + compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price; + const selections = variant.selections.map((selection) => ({ + variationId: selection.variation.id.toString(), + variationName: selection.variation.name, + optionId: selection.option.id.toString(), + value: selection.option.label, + })); + + return { + id: item.id.toString(), + storeItemId: variant.storeItemId.toString(), + storeItemVariantId: variant.id.toString(), + productId: product.id.toString(), + productTitle: product.title, + productNameFa: (content.nameFa as string | null | undefined) ?? '', + productImage: product.featuredMedia?.publicUrl ?? null, + sku: variant.sku, + selections, + label: selections.map((entry) => entry.value).join(' · ') || product.title, + quantity: item.quantity, + unitPrice: effectivePrice, + compareAtPrice: price, + lineTotal: effectivePrice * item.quantity, + stockQuantity: variant.stockQuantity, + }; + } + + private asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async assertCustomerAccess(businessId: bigint, actor: AuthUser) { + if (await this.permissions.isSuperAdmin(actor.id)) { + return; + } + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId: actor.id }, + }, + }); + + if (!membership) { + throw new ForbiddenException('You are not a customer of this business'); + } + } +} diff --git a/src/cart/dto/cart.dto.ts b/src/cart/dto/cart.dto.ts new file mode 100644 index 0000000..09c6650 --- /dev/null +++ b/src/cart/dto/cart.dto.ts @@ -0,0 +1,73 @@ +import { + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateTransactionPaymentDto } from '../../transactions/dto/transaction.dto'; + +export class AddCartItemDto { + @IsString() + @MinLength(1) + storeItemVariantId!: string; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + quantity?: number; +} + +export class UpdateCartItemDto { + @IsInt() + @Min(1) + @Type(() => Number) + quantity!: number; +} + +export class ShippingAddressDto { + @IsString() + @MinLength(1) + province!: string; + + @IsString() + @MinLength(1) + city!: string; + + @IsString() + @MinLength(1) + address!: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + landline?: string; +} + +export class CheckoutCartDto { + @IsOptional() + @IsString() + @MinLength(1) + addressId?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ShippingAddressDto) + shippingAddress?: ShippingAddressDto; + + @IsOptional() + @IsString() + customerNotes?: string; + + @ValidateNested() + @Type(() => CreateTransactionPaymentDto) + payment!: CreateTransactionPaymentDto; +} diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts new file mode 100644 index 0000000..0d05a84 --- /dev/null +++ b/src/categories/categories.controller.ts @@ -0,0 +1,176 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CategoriesService } from './categories.service'; +import { CategoryTechnicalFormService } from './category-technical-form.service'; +import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service'; +import { CategoryAiService } from './category-ai.service'; +import { CategoryVariationsService } from './category-variations.service'; +import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto'; +import { + ReplaceCategoryTechnicalFormDto, + SuggestCategoryTechnicalFormDto, +} from './dto/category-technical-form.dto'; +import { ReplaceCategoryVariationsDto } from './dto/category-variation.dto'; +import { GenerateCategoriesDto } from './dto/category-ai.dto'; + +@Controller('businesses/:businessId/categories') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class CategoriesController { + constructor( + private readonly service: CategoriesService, + private readonly variationsService: CategoryVariationsService, + private readonly technicalFormService: CategoryTechnicalFormService, + private readonly technicalFormAiService: CategoryTechnicalFormAiService, + private readonly categoryAiService: CategoryAiService, + ) {} + + @Get('color-presets') + @RequireBusinessPermission('categories.read') + listColorPresets() { + return this.variationsService.listColorPresets(); + } + + @Get() + @RequireBusinessPermission('categories.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListCategoriesDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Post('ai-generate') + @RequireBusinessPermission('categories.create') + generateWithAi( + @Param('businessId') businessId: string, + @Body() dto: GenerateCategoriesDto, + @CurrentUser() user: AuthUser, + ) { + return this.categoryAiService.generateProductCategories(businessId, dto, user); + } + + @Post() + @RequireBusinessPermission('categories.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateCategoryDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':categoryId') + @RequireBusinessPermission('categories.update') + update( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @Body() dto: UpdateCategoryDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, categoryId, dto, user); + } + + @Get(':categoryId/variations') + @RequireBusinessPermission('categories.read') + listVariations( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @CurrentUser() user: AuthUser, + ) { + return this.variationsService.listForCategory(businessId, categoryId, user); + } + + @Put(':categoryId/variations') + @RequireBusinessPermission('categories.update') + replaceVariations( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @Body() dto: ReplaceCategoryVariationsDto, + @CurrentUser() user: AuthUser, + ) { + return this.variationsService.replaceForCategory( + businessId, + categoryId, + dto, + user, + ); + } + + @Get(':categoryId/technical-form') + @RequireBusinessPermission('categories.read') + getTechnicalForm( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @CurrentUser() user: AuthUser, + ) { + return this.technicalFormService.getForCategory(businessId, categoryId, user); + } + + @Put(':categoryId/technical-form') + @RequireBusinessPermission('categories.update') + replaceTechnicalForm( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @Body() dto: ReplaceCategoryTechnicalFormDto, + @CurrentUser() user: AuthUser, + ) { + return this.technicalFormService.replaceForCategory( + businessId, + categoryId, + dto, + user, + ); + } + + @Post(':categoryId/technical-form/ai-suggest') + @RequireBusinessPermission('categories.update') + suggestTechnicalForm( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @Body() dto: SuggestCategoryTechnicalFormDto, + @CurrentUser() user: AuthUser, + ) { + return this.technicalFormAiService.suggestForCategory( + businessId, + categoryId, + dto, + user, + ); + } + + @Delete(':categoryId') + @RequireBusinessPermission('categories.delete') + remove( + @Param('businessId') businessId: string, + @Param('categoryId') categoryId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, categoryId, user); + } +} + +@Controller('tenants/:host/categories') +export class PublicCategoriesController { + constructor(private readonly service: CategoriesService) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListCategoriesDto) { + return this.service.listPublic(host, query); + } +} diff --git a/src/categories/categories.module.ts b/src/categories/categories.module.ts new file mode 100644 index 0000000..9e9554b --- /dev/null +++ b/src/categories/categories.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { CategoriesController, PublicCategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; +import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service'; +import { CategoryAiService } from './category-ai.service'; +import { CategoryTechnicalFormService } from './category-technical-form.service'; +import { CategoryVariationsService } from './category-variations.service'; + +@Module({ + imports: [AuthModule, TenantModule], + controllers: [CategoriesController, PublicCategoriesController], + providers: [ + CategoriesService, + CategoryVariationsService, + CategoryTechnicalFormService, + CategoryTechnicalFormAiService, + CategoryAiService, + ], + exports: [CategoryVariationsService, CategoryTechnicalFormService], +}) +export class CategoriesModule {} diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts new file mode 100644 index 0000000..1d7f658 --- /dev/null +++ b/src/categories/categories.service.ts @@ -0,0 +1,320 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MediaEntityType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'category' + ); +} + +@Injectable() +export class CategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async list(businessIdRaw: string, query: ListCategoriesDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.read'); + + const entityType = query.entityType ?? MediaEntityType.product; + + const items = await this.prisma.category.findMany({ + where: { businessId, entityType, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + include: { + _count: { select: { variations: true } }, + }, + }); + + return { + items: items.map((item) => + this.serialize(item, item._count.variations), + ), + }; + } + + async listPublic(host: string, query: ListCategoriesDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const entityType = query.entityType ?? MediaEntityType.product; + + const items = await this.prisma.category.findMany({ + where: { businessId, entityType, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }); + + return { + items: items.map((item) => this.serialize(item)), + }; + } + + async create(businessIdRaw: string, dto: CreateCategoryDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.create'); + + const parentId = dto.parentId ? BigInt(dto.parentId) : null; + + if (parentId) { + const parent = await this.prisma.category.findFirst({ + where: { id: parentId, businessId, entityType: dto.entityType }, + }); + if (!parent) { + throw new BadRequestException('Parent category not found for this business'); + } + } + + const slug = await this.ensureUniqueSlug( + businessId, + dto.entityType, + dto.slug ?? slugify(dto.name), + ); + + const created = await this.prisma.category.create({ + data: { + businessId, + entityType: dto.entityType, + parentId, + name: dto.name.trim(), + nameFa: dto.nameFa?.trim() || null, + slug, + description: dto.description?.trim() || null, + sortOrder: dto.sortOrder ?? 0, + }, + }); + + return { + message: 'Category created successfully', + category: this.serialize(created), + }; + } + + async update( + businessIdRaw: string, + categoryIdRaw: string, + dto: UpdateCategoryDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.update'); + + const existing = await this.prisma.category.findFirst({ + where: { id: categoryId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Category not found'); + } + + let parentId: bigint | null | undefined = undefined; + + if (dto.parentId !== undefined) { + if (dto.parentId === null || dto.parentId === '') { + parentId = null; + } else { + parentId = BigInt(dto.parentId); + if (parentId === categoryId) { + throw new BadRequestException('Category cannot be its own parent'); + } + const parent = await this.prisma.category.findFirst({ + where: { id: parentId, businessId, entityType: existing.entityType }, + }); + if (!parent) { + throw new BadRequestException('Parent category not found for this business'); + } + const descendantIds = await this.collectDescendantIds(categoryId); + if (descendantIds.includes(parentId)) { + throw new BadRequestException('Cannot move category under its own descendant'); + } + } + } + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug( + businessId, + existing.entityType, + dto.slug, + categoryId, + ); + } else if (dto.name && dto.name !== existing.name) { + slug = await this.ensureUniqueSlug( + businessId, + existing.entityType, + slugify(dto.name), + categoryId, + ); + } + + const updated = await this.prisma.category.update({ + where: { id: categoryId }, + data: { + ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), + ...(dto.nameFa !== undefined ? { nameFa: dto.nameFa?.trim() || null } : {}), + ...(dto.description !== undefined + ? { description: dto.description?.trim() || null } + : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), + ...(parentId !== undefined ? { parentId } : {}), + slug, + }, + }); + + return { + message: 'Category updated successfully', + category: this.serialize(updated), + }; + } + + async remove(businessIdRaw: string, categoryIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.delete'); + + const existing = await this.prisma.category.findFirst({ + where: { id: categoryId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Category not found'); + } + + const descendantIds = await this.collectDescendantIds(categoryId); + + await this.prisma.$transaction([ + this.prisma.category.deleteMany({ + where: { id: { in: descendantIds } }, + }), + ]); + + return { + message: 'Category deleted successfully', + deletedIds: descendantIds.map((id) => id.toString()), + }; + } + + private async collectDescendantIds(rootId: bigint): Promise { + const all = await this.prisma.category.findMany({ + where: { businessId: (await this.getBusinessIdForCategory(rootId))! }, + select: { id: true, parentId: true }, + }); + + const result: bigint[] = [rootId]; + const queue = [rootId]; + + while (queue.length > 0) { + const current = queue.shift()!; + const children = all.filter((c) => c.parentId === current).map((c) => c.id); + for (const childId of children) { + result.push(childId); + queue.push(childId); + } + } + + return result; + } + + private async getBusinessIdForCategory(categoryId: bigint) { + const category = await this.prisma.category.findUnique({ + where: { id: categoryId }, + select: { businessId: true }, + }); + return category?.businessId; + } + + private async ensureUniqueSlug( + businessId: bigint, + entityType: MediaEntityType, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.category.findFirst({ + where: { + businessId, + entityType, + slug, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${permission} for this business`); + } + } + + private serialize( + category: { + id: bigint; + businessId: bigint; + entityType: MediaEntityType; + parentId: bigint | null; + name: string; + nameFa: string | null; + slug: string; + description: string | null; + sortOrder: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + }, + variationCount = 0, + ) { + return { + id: category.id.toString(), + businessId: category.businessId.toString(), + entityType: category.entityType, + parentId: category.parentId?.toString() ?? null, + name: category.name, + nameFa: category.nameFa, + slug: category.slug, + description: category.description, + sortOrder: category.sortOrder, + isActive: category.isActive, + variationCount, + createdAt: category.createdAt, + updatedAt: category.updatedAt, + }; + } +} diff --git a/src/categories/category-ai.service.ts b/src/categories/category-ai.service.ts new file mode 100644 index 0000000..6d76e45 --- /dev/null +++ b/src/categories/category-ai.service.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { + requestAiJsonCompletion, + resolveAiProvider, +} from '../common/ai-provider.util'; +import { PrismaService } from '../prisma/prisma.service'; +import { GenerateCategoriesDto } from './dto/category-ai.dto'; + +type AiCategoryNode = { + nameEn?: string; + nameFa?: string; + description?: string; + children?: AiCategoryNode[]; +}; + +type AiCategoryTreeResponse = { + categories?: AiCategoryNode[]; +}; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'category' + ); +} + +const MAX_DEPTH = 3; +const MAX_TOTAL = 40; + +@Injectable() +export class CategoryAiService { + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async generateProductCategories( + businessIdRaw: string, + dto: GenerateCategoriesDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.create'); + + const tree = await this.generateTree(dto.prompt.trim()); + const created = await this.prisma.$transaction(async (tx) => { + return this.createTree( + tx, + businessId, + MediaEntityType.product, + tree, + null, + 0, + { count: 0 }, + ); + }); + + return { + message: `Created ${created.length} categor${created.length === 1 ? 'y' : 'ies'} with AI.`, + categories: created.map((item) => this.serialize(item)), + }; + } + + private async generateTree(prompt: string): Promise { + const provider = resolveAiProvider(this.config); + + const systemPrompt = `You design product category trees for Iranian e-commerce stores. +Return ONLY valid JSON with this shape: +{ + "categories": [ + { + "nameEn": "Category name in English", + "nameFa": "نام فارسی", + "description": "Short English description", + "children": [ + { + "nameEn": "Subcategory", + "nameFa": "زیردسته", + "description": "Optional description", + "children": [] + } + ] + } + ] +} + +Rules: +- Generate a practical category hierarchy based on the user prompt. +- Use 2-6 top-level categories when appropriate. +- Add subcategories only where they help shoppers browse (max depth ${MAX_DEPTH}). +- Every node needs nameEn and nameFa (Farsi in Persian script). +- Descriptions are optional, max 120 characters, English only. +- Do not duplicate category names at the same level. +- Total nodes must not exceed ${MAX_TOTAL}.`; + + const userPrompt = JSON.stringify({ prompt }); + + let content: string; + try { + content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt); + } catch (err) { + const message = err instanceof Error ? err.message : 'AI request failed'; + throw new BadRequestException(message); + } + + let parsed: AiCategoryTreeResponse; + try { + parsed = JSON.parse(content) as AiCategoryTreeResponse; + } catch { + throw new BadRequestException('AI returned invalid JSON'); + } + + const categories = Array.isArray(parsed.categories) ? parsed.categories : []; + const normalized = this.normalizeNodes(categories, 0); + + if (!normalized.length) { + throw new BadRequestException('AI did not return any valid categories'); + } + + return normalized; + } + + private normalizeNodes(nodes: AiCategoryNode[], depth: number): AiCategoryNode[] { + if (depth >= MAX_DEPTH) { + return []; + } + + const result: AiCategoryNode[] = []; + const seen = new Set(); + + for (const raw of nodes) { + const nameEn = String(raw.nameEn ?? '').trim(); + const nameFa = String(raw.nameFa ?? '').trim(); + if (!nameEn || nameEn.length < 2) continue; + + const key = nameEn.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + + const children = Array.isArray(raw.children) + ? this.normalizeNodes(raw.children, depth + 1) + : []; + + result.push({ + nameEn, + nameFa: nameFa || nameEn, + description: String(raw.description ?? '').trim().slice(0, 120) || undefined, + children, + }); + + if (result.length >= MAX_TOTAL) break; + } + + return result; + } + + private async createTree( + tx: Prisma.TransactionClient, + businessId: bigint, + entityType: MediaEntityType, + nodes: AiCategoryNode[], + parentId: bigint | null, + depth: number, + state: { count: number }, + ) { + const created: Prisma.CategoryGetPayload[] = []; + + for (let index = 0; index < nodes.length; index += 1) { + if (state.count >= MAX_TOTAL) break; + + const node = nodes[index]; + const slug = await this.ensureUniqueSlug( + tx, + businessId, + entityType, + slugify(node.nameEn!), + ); + + const category = await tx.category.create({ + data: { + businessId, + entityType, + parentId, + name: node.nameEn!, + nameFa: node.nameFa?.trim() || null, + slug, + description: node.description?.trim() || null, + sortOrder: index, + }, + }); + + created.push(category); + state.count += 1; + + if (node.children?.length && depth + 1 < MAX_DEPTH) { + const childCreated = await this.createTree( + tx, + businessId, + entityType, + node.children, + category.id, + depth + 1, + state, + ); + created.push(...childCreated); + } + } + + return created; + } + + private async ensureUniqueSlug( + tx: Prisma.TransactionClient, + businessId: bigint, + entityType: MediaEntityType, + baseSlug: string, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await tx.category.findFirst({ + where: { businessId, entityType, slug }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private serialize(category: Prisma.CategoryGetPayload) { + return { + id: category.id.toString(), + businessId: category.businessId.toString(), + entityType: category.entityType, + parentId: category.parentId?.toString() ?? null, + name: category.name, + nameFa: category.nameFa, + slug: category.slug, + description: category.description, + sortOrder: category.sortOrder, + isActive: category.isActive, + variationCount: 0, + createdAt: category.createdAt, + updatedAt: category.updatedAt, + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${permission} for this business`); + } + } +} diff --git a/src/categories/category-technical-form-ai.service.ts b/src/categories/category-technical-form-ai.service.ts new file mode 100644 index 0000000..d495d96 --- /dev/null +++ b/src/categories/category-technical-form-ai.service.ts @@ -0,0 +1,184 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MediaEntityType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { + requestAiJsonCompletion, + resolveAiProvider, +} from '../common/ai-provider.util'; +import { PrismaService } from '../prisma/prisma.service'; +import { CategoryTechnicalFormService } from './category-technical-form.service'; +import { + SuggestCategoryTechnicalFormDto, + TechnicalFormFieldInputDto, +} from './dto/category-technical-form.dto'; + +type AiTechnicalFormDraft = { + fields?: { + label?: string; + type?: string; + isRequired?: boolean; + options?: string[]; + }[]; +}; + +const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']); + +@Injectable() +export class CategoryTechnicalFormAiService { + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + private readonly technicalFormService: CategoryTechnicalFormService, + ) {} + + async suggestForCategory( + businessIdRaw: string, + categoryIdRaw: string, + dto: SuggestCategoryTechnicalFormDto, + actor: AuthUser, + ) { + const existing = await this.technicalFormService.getForCategory( + businessIdRaw, + categoryIdRaw, + actor, + ); + + const categoryName = await this.getCategoryName(businessIdRaw, categoryIdRaw); + + const fields = await this.generateFields({ + categoryName, + hint: dto.hint?.trim(), + existingFieldCount: existing.form?.fields.length ?? 0, + }); + + return { + message: 'Technical form draft generated. Review fields before saving.', + fields, + }; + } + + private async getCategoryName(businessIdRaw: string, categoryIdRaw: string) { + const category = await this.prisma.category.findFirst({ + where: { + id: BigInt(categoryIdRaw), + businessId: BigInt(businessIdRaw), + entityType: MediaEntityType.product, + isActive: true, + }, + select: { name: true }, + }); + + if (!category) { + throw new NotFoundException('Product category not found'); + } + + return category.name; + } + + private async generateFields(input: { + categoryName: string; + hint?: string; + existingFieldCount: number; + }): Promise { + const provider = resolveAiProvider(this.config); + + const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace. +Return ONLY valid JSON with this shape: +{ + "fields": [ + { + "label": "Field label in English", + "type": "text | textarea | select | multi_select", + "isRequired": true, + "options": ["Option A", "Option B"] + } + ] +} + +Rules: +- Generate 5-12 practical fields that buyers need for products in the given category. +- Use a mix of text, textarea, select, and multi_select where appropriate. +- select and multi_select fields must include 3-8 realistic options. +- text fields are for short values (dimensions, weight, model year). +- textarea fields are for longer specs (features, care instructions). +- Mark important buyer-facing specs as required. +- Labels must be unique and human-readable. +- Do not include price, stock, SKU, or warranty period fields. +- Prefer metric units common in Iran when relevant.`; + + const userPrompt = JSON.stringify({ + category: input.categoryName, + hint: input.hint || null, + existingFieldCount: input.existingFieldCount, + note: + input.existingFieldCount > 0 + ? 'Category already has a form; suggest a fresh complete replacement set.' + : 'Category has no form yet.', + }); + + let content: string; + try { + content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt); + } catch (err) { + const message = err instanceof Error ? err.message : 'AI request failed'; + throw new BadRequestException(message); + } + + let parsed: AiTechnicalFormDraft; + try { + parsed = JSON.parse(content) as AiTechnicalFormDraft; + } catch { + throw new BadRequestException('AI returned invalid JSON'); + } + + return this.normalizeFields(parsed); + } + + private normalizeFields(draft: AiTechnicalFormDraft): TechnicalFormFieldInputDto[] { + const rawFields = Array.isArray(draft.fields) ? draft.fields : []; + const labels = new Set(); + const fields: TechnicalFormFieldInputDto[] = []; + + for (const raw of rawFields) { + const label = String(raw.label ?? '').trim(); + if (!label) continue; + + const normalizedLabel = label.toLowerCase(); + if (labels.has(normalizedLabel)) continue; + labels.add(normalizedLabel); + + const type = ALLOWED_TYPES.has(String(raw.type)) + ? (raw.type as TechnicalFormFieldInputDto['type']) + : 'text'; + + const field: TechnicalFormFieldInputDto = { + label, + type, + isRequired: Boolean(raw.isRequired), + }; + + if (type === 'select' || type === 'multi_select') { + const options = Array.isArray(raw.options) + ? [...new Set(raw.options.map((option) => String(option).trim()).filter(Boolean))] + : []; + + if (!options.length) continue; + field.options = options.slice(0, 12); + } + + fields.push(field); + if (fields.length >= 12) break; + } + + if (!fields.length) { + throw new BadRequestException('AI did not return any valid technical form fields'); + } + + return fields; + } +} diff --git a/src/categories/category-technical-form.service.ts b/src/categories/category-technical-form.service.ts new file mode 100644 index 0000000..2e610f8 --- /dev/null +++ b/src/categories/category-technical-form.service.ts @@ -0,0 +1,253 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MediaEntityType, TechnicalFieldType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ReplaceCategoryTechnicalFormDto, + TechnicalFormFieldInputDto, +} from './dto/category-technical-form.dto'; + +function slugifyKey(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'field' + ); +} + +@Injectable() +export class CategoryTechnicalFormService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async getForCategory( + businessIdRaw: string, + categoryIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.read'); + await this.getProductCategory(businessId, categoryId); + + const form = await this.prisma.categoryTechnicalForm.findUnique({ + where: { categoryId }, + include: { + fields: { + orderBy: { sortOrder: 'asc' }, + include: { + options: { orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + }); + + if (!form) { + return { form: null }; + } + + return { form: this.serializeForm(form) }; + } + + async replaceForCategory( + businessIdRaw: string, + categoryIdRaw: string, + dto: ReplaceCategoryTechnicalFormDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.update'); + await this.getProductCategory(businessId, categoryId); + + this.validateFieldInputs(dto.fields); + + await this.prisma.$transaction(async (tx) => { + await tx.categoryTechnicalForm.deleteMany({ + where: { businessId, categoryId }, + }); + + if (!dto.fields.length) { + return; + } + + const form = await tx.categoryTechnicalForm.create({ + data: { businessId, categoryId }, + }); + + const usedKeys = new Set(); + + for (const [index, field] of dto.fields.entries()) { + let fieldKey = slugifyKey(field.label); + if (usedKeys.has(fieldKey)) { + let suffix = 2; + while (usedKeys.has(`${fieldKey}-${suffix}`)) { + suffix += 1; + } + fieldKey = `${fieldKey}-${suffix}`; + } + usedKeys.add(fieldKey); + + const createdField = await tx.categoryTechnicalFormField.create({ + data: { + formId: form.id, + label: field.label.trim(), + fieldKey, + fieldType: field.type as TechnicalFieldType, + isRequired: field.isRequired ?? false, + sortOrder: index, + }, + }); + + if (field.type === 'select' || field.type === 'multi_select') { + const uniqueOptions = [ + ...new Set(field.options!.map((o) => o.trim()).filter(Boolean)), + ]; + + await tx.categoryTechnicalFormFieldOption.createMany({ + data: uniqueOptions.map((label, optionIndex) => ({ + fieldId: createdField.id, + label, + value: slugifyKey(label) || `option-${optionIndex + 1}`, + sortOrder: optionIndex, + })), + }); + } + } + }); + + return this.getForCategory(businessIdRaw, categoryIdRaw, actor); + } + + async getFormForCategory(businessId: bigint, categoryId: bigint) { + const form = await this.prisma.categoryTechnicalForm.findUnique({ + where: { categoryId }, + include: { + fields: { + orderBy: { sortOrder: 'asc' }, + include: { + options: { orderBy: { sortOrder: 'asc' } }, + }, + }, + }, + }); + + if (!form || form.businessId !== businessId) { + return null; + } + + return this.serializeForm(form); + } + + private validateFieldInputs(fields: TechnicalFormFieldInputDto[]) { + const labels = new Set(); + + for (const field of fields) { + const label = field.label.trim().toLowerCase(); + if (!label) { + throw new BadRequestException('Each field must have a label'); + } + if (labels.has(label)) { + throw new BadRequestException( + `Duplicate field label "${field.label}"`, + ); + } + labels.add(label); + + if (field.type === 'select' || field.type === 'multi_select') { + const options = field.options?.map((o) => o.trim()).filter(Boolean) ?? []; + if (!options.length) { + throw new BadRequestException( + `Field "${field.label}" requires at least one option`, + ); + } + } + } + } + + private async getProductCategory(businessId: bigint, categoryId: bigint) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.product, + isActive: true, + }, + }); + + if (!category) { + throw new NotFoundException('Product category not found'); + } + + return category; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } + + private serializeForm(form: { + id: bigint; + categoryId: bigint; + fields: { + id: bigint; + label: string; + fieldKey: string; + fieldType: TechnicalFieldType; + isRequired: boolean; + sortOrder: number; + options: { + id: bigint; + label: string; + value: string; + sortOrder: number; + }[]; + }[]; + }) { + return { + id: form.id.toString(), + categoryId: form.categoryId.toString(), + fields: form.fields.map((field) => ({ + id: field.id.toString(), + label: field.label, + key: field.fieldKey, + type: field.fieldType, + isRequired: field.isRequired, + sortOrder: field.sortOrder, + options: + field.fieldType === 'select' || field.fieldType === 'multi_select' + ? field.options.map((option) => ({ + id: option.id.toString(), + label: option.label, + value: option.value, + sortOrder: option.sortOrder, + })) + : [], + })), + }; + } +} diff --git a/src/categories/category-variations.service.ts b/src/categories/category-variations.service.ts new file mode 100644 index 0000000..13e94db --- /dev/null +++ b/src/categories/category-variations.service.ts @@ -0,0 +1,234 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MediaEntityType, VariationType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + COLOR_PRESETS, + getColorHex, + isColorPreset, +} from './color-presets'; +import { + CategoryVariationInputDto, + ReplaceCategoryVariationsDto, +} from './dto/category-variation.dto'; + +function slugifyValue(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +@Injectable() +export class CategoryVariationsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + listColorPresets() { + return { items: COLOR_PRESETS }; + } + + async listForCategory( + businessIdRaw: string, + categoryIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.read'); + await this.getProductCategory(businessId, categoryId); + + const variations = await this.prisma.categoryVariation.findMany({ + where: { businessId, categoryId }, + include: { options: { orderBy: { sortOrder: 'asc' } } }, + orderBy: { sortOrder: 'asc' }, + }); + + return { items: variations.map((item) => this.serialize(item)) }; + } + + async replaceForCategory( + businessIdRaw: string, + categoryIdRaw: string, + dto: ReplaceCategoryVariationsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertPermission(businessId, actor.id, 'categories.update'); + await this.getProductCategory(businessId, categoryId); + + this.validateVariationInputs(dto.variations); + + await this.prisma.$transaction(async (tx) => { + await tx.categoryVariation.deleteMany({ + where: { businessId, categoryId }, + }); + + for (const [index, variation] of dto.variations.entries()) { + const created = await tx.categoryVariation.create({ + data: { + businessId, + categoryId, + name: this.resolveVariationName(variation), + variationType: variation.type as VariationType, + sortOrder: index, + }, + }); + + const uniqueValues = [...new Set(variation.values.map((v) => v.trim()).filter(Boolean))]; + + await tx.categoryVariationOption.createMany({ + data: uniqueValues.map((label, optionIndex) => ({ + variationId: created.id, + label, + value: slugifyValue(label) || `option-${optionIndex + 1}`, + colorHex: + variation.type === 'color' ? getColorHex(label) : null, + sortOrder: optionIndex, + })), + }); + } + }); + + return this.listForCategory(businessIdRaw, categoryIdRaw, actor); + } + + async getForProductCategory(businessId: bigint, categoryId: bigint) { + const variations = await this.prisma.categoryVariation.findMany({ + where: { businessId, categoryId }, + include: { options: { orderBy: { sortOrder: 'asc' } } }, + orderBy: { sortOrder: 'asc' }, + }); + + return variations.map((item) => this.serialize(item)); + } + + private validateVariationInputs(variations: CategoryVariationInputDto[]) { + const colorCount = variations.filter((v) => v.type === 'color').length; + const sizeCount = variations.filter((v) => v.type === 'size').length; + + if (colorCount > 1) { + throw new BadRequestException('Only one color variation is allowed per category'); + } + + if (sizeCount > 1) { + throw new BadRequestException('Only one size variation is allowed per category'); + } + + const customNames = new Set(); + + for (const variation of variations) { + if (variation.type === 'color') { + for (const value of variation.values) { + if (!isColorPreset(value.trim())) { + throw new BadRequestException( + `Invalid color "${value}". Choose from the predefined color palette.`, + ); + } + } + } + + if (variation.type === 'size' || variation.type === 'custom') { + const values = variation.values.map((v) => v.trim()).filter(Boolean); + if (!values.length) { + throw new BadRequestException('Each variation must have at least one value'); + } + } + + if (variation.type === 'custom') { + const name = variation.name.trim().toLowerCase(); + if (!name) { + throw new BadRequestException('Custom variations require a name'); + } + if (customNames.has(name)) { + throw new BadRequestException( + `Duplicate custom variation name "${variation.name}"`, + ); + } + customNames.add(name); + } + } + } + + private resolveVariationName(variation: CategoryVariationInputDto): string { + if (variation.type === 'color') { + return 'Color'; + } + if (variation.type === 'size') { + return 'Size'; + } + return variation.name.trim(); + } + + private async getProductCategory(businessId: bigint, categoryId: bigint) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.product, + isActive: true, + }, + }); + + if (!category) { + throw new NotFoundException('Product category not found'); + } + + return category; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${permission} for this business`); + } + } + + private serialize(variation: { + id: bigint; + name: string; + variationType: VariationType; + sortOrder: number; + options: { + id: bigint; + label: string; + value: string; + colorHex: string | null; + sortOrder: number; + }[]; + }) { + return { + id: variation.id.toString(), + name: variation.name, + type: variation.variationType, + sortOrder: variation.sortOrder, + values: variation.options.map((option) => option.label), + options: variation.options.map((option) => ({ + id: option.id.toString(), + label: option.label, + value: option.value, + colorHex: option.colorHex, + sortOrder: option.sortOrder, + })), + }; + } +} diff --git a/src/categories/color-presets.ts b/src/categories/color-presets.ts new file mode 100644 index 0000000..a8395e8 --- /dev/null +++ b/src/categories/color-presets.ts @@ -0,0 +1,27 @@ +export const COLOR_PRESETS = [ + { name: 'Red', hex: '#EF4444' }, + { name: 'Blue', hex: '#3B82F6' }, + { name: 'Green', hex: '#22C55E' }, + { name: 'Black', hex: '#111827' }, + { name: 'White', hex: '#FFFFFF' }, + { name: 'Silver', hex: '#C0C0C0' }, + { name: 'Gold', hex: '#D4AF37' }, + { name: 'Navy', hex: '#1E3A5F' }, + { name: 'Yellow', hex: '#EAB308' }, + { name: 'Orange', hex: '#F97316' }, + { name: 'Purple', hex: '#A855F7' }, + { name: 'Pink', hex: '#EC4899' }, + { name: 'Brown', hex: '#92400E' }, + { name: 'Gray', hex: '#6B7280' }, + { name: 'Beige', hex: '#D4C4A8' }, +] as const; + +export const COLOR_PRESET_NAMES = COLOR_PRESETS.map((color) => color.name); + +export function isColorPreset(value: string): boolean { + return COLOR_PRESET_NAMES.some((name) => name === value); +} + +export function getColorHex(name: string): string | null { + return COLOR_PRESETS.find((color) => color.name === name)?.hex ?? null; +} diff --git a/src/categories/dto/category-ai.dto.ts b/src/categories/dto/category-ai.dto.ts new file mode 100644 index 0000000..174bd54 --- /dev/null +++ b/src/categories/dto/category-ai.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class GenerateCategoriesDto { + @IsString() + @MinLength(10) + prompt!: string; +} diff --git a/src/categories/dto/category-technical-form.dto.ts b/src/categories/dto/category-technical-form.dto.ts new file mode 100644 index 0000000..74d2cd7 --- /dev/null +++ b/src/categories/dto/category-technical-form.dto.ts @@ -0,0 +1,48 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsIn, + IsOptional, + IsString, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +export class TechnicalFormFieldInputDto { + @IsString() + @MinLength(1) + label!: string; + + @IsString() + @IsIn(['text', 'textarea', 'select', 'multi_select']) + type!: 'text' | 'textarea' | 'select' | 'multi_select'; + + @IsOptional() + @IsBoolean() + isRequired?: boolean; + + @ValidateIf((o: TechnicalFormFieldInputDto) => + o.type === 'select' || o.type === 'multi_select', + ) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + options?: string[]; +} + +export class ReplaceCategoryTechnicalFormDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TechnicalFormFieldInputDto) + fields!: TechnicalFormFieldInputDto[]; +} + +export class SuggestCategoryTechnicalFormDto { + @IsOptional() + @IsString() + @MinLength(1) + hint?: string; +} diff --git a/src/categories/dto/category-variation.dto.ts b/src/categories/dto/category-variation.dto.ts new file mode 100644 index 0000000..5984dab --- /dev/null +++ b/src/categories/dto/category-variation.dto.ts @@ -0,0 +1,31 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsIn, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class CategoryVariationInputDto { + @IsString() + @IsIn(['color', 'size', 'custom']) + type!: 'color' | 'size' | 'custom'; + + @IsString() + @MinLength(1) + name!: string; + + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + values!: string[]; +} + +export class ReplaceCategoryVariationsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CategoryVariationInputDto) + variations!: CategoryVariationInputDto[]; +} diff --git a/src/categories/dto/category.dto.ts b/src/categories/dto/category.dto.ts new file mode 100644 index 0000000..668f5ac --- /dev/null +++ b/src/categories/dto/category.dto.ts @@ -0,0 +1,77 @@ +import { MediaEntityType } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator'; + +export class ListCategoriesDto { + @IsOptional() + @IsEnum(MediaEntityType) + entityType?: MediaEntityType; +} + +export class CreateCategoryDto { + @IsEnum(MediaEntityType) + entityType!: MediaEntityType; + + @IsOptional() + @IsString() + parentId?: string; + + @IsString() + @MinLength(2) + name!: string; + + @IsOptional() + @IsString() + @MinLength(2) + nameFa?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'slug must be lowercase letters, numbers, and hyphens', + }) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; +} + +export class UpdateCategoryDto { + @IsOptional() + @IsString() + parentId?: string | null; + + @IsOptional() + @IsString() + @MinLength(2) + name?: string; + + @IsOptional() + @IsString() + nameFa?: string | null; + + @IsOptional() + @IsString() + description?: string | null; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + isActive?: boolean; +} diff --git a/src/cities/cities.controller.ts b/src/cities/cities.controller.ts new file mode 100644 index 0000000..82cf564 --- /dev/null +++ b/src/cities/cities.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { CitiesService } from './cities.service'; +import { ListCitiesDto } from './dto/list-cities.dto'; + +@Controller('cities') +export class CitiesController { + constructor(private readonly service: CitiesService) {} + + @Get() + list(@Query() query: ListCitiesDto) { + return this.service.list(query); + } + + @Get(':cityId') + getOne(@Param('cityId') cityId: string) { + return this.service.getOne(cityId); + } +} diff --git a/src/cities/cities.module.ts b/src/cities/cities.module.ts new file mode 100644 index 0000000..41b7b1e --- /dev/null +++ b/src/cities/cities.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CitiesController } from './cities.controller'; +import { CitiesService } from './cities.service'; + +@Module({ + controllers: [CitiesController], + providers: [CitiesService], + exports: [CitiesService], +}) +export class CitiesModule {} diff --git a/src/cities/cities.service.ts b/src/cities/cities.service.ts new file mode 100644 index 0000000..52a4cab --- /dev/null +++ b/src/cities/cities.service.ts @@ -0,0 +1,100 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { CityLevel, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { ListCitiesDto } from './dto/list-cities.dto'; + +type CityRecord = Prisma.CityGetPayload<{ + select: { + id: true; + parentId: true; + level: true; + nameFa: true; + nameEn: true; + landlineCode: true; + slug: true; + sortOrder: true; + }; +}>; + +@Injectable() +export class CitiesService { + constructor(private readonly prisma: PrismaService) {} + + async list(query: ListCitiesDto) { + const where: Prisma.CityWhereInput = { + isActive: true, + ...(query.level ? { level: query.level } : {}), + }; + + if (query.parentId) { + where.parentId = BigInt(query.parentId); + } else if (query.parentSlug) { + const parent = await this.prisma.city.findFirst({ + where: { slug: query.parentSlug, isActive: true }, + select: { id: true }, + }); + + if (!parent) { + return { items: [] }; + } + + where.parentId = parent.id; + } else if (query.level === CityLevel.province || query.level === CityLevel.city) { + throw new BadRequestException('parentId or parentSlug is required for this level'); + } else { + where.level = CityLevel.country; + } + + const items = await this.prisma.city.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }], + select: { + id: true, + parentId: true, + level: true, + nameFa: true, + nameEn: true, + landlineCode: true, + slug: true, + sortOrder: true, + }, + }); + + return { items: items.map((item) => this.serialize(item)) }; + } + + async getOne(cityIdRaw: string) { + const city = await this.prisma.city.findFirst({ + where: { id: BigInt(cityIdRaw), isActive: true }, + select: { + id: true, + parentId: true, + level: true, + nameFa: true, + nameEn: true, + landlineCode: true, + slug: true, + sortOrder: true, + }, + }); + + if (!city) { + throw new NotFoundException('City not found'); + } + + return { city: this.serialize(city) }; + } + + private serialize(city: CityRecord) { + return { + id: city.id.toString(), + parentId: city.parentId?.toString() ?? null, + level: city.level, + nameFa: city.nameFa, + nameEn: city.nameEn, + landlineCode: city.landlineCode, + slug: city.slug, + sortOrder: city.sortOrder, + }; + } +} diff --git a/src/cities/dto/list-cities.dto.ts b/src/cities/dto/list-cities.dto.ts new file mode 100644 index 0000000..99bac93 --- /dev/null +++ b/src/cities/dto/list-cities.dto.ts @@ -0,0 +1,19 @@ +import { CityLevel } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; + +export class ListCitiesDto { + @IsOptional() + @IsEnum(CityLevel) + level?: CityLevel; + + @IsOptional() + @IsString() + parentId?: string; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { + message: 'parentSlug must be lowercase letters, numbers, and hyphens', + }) + parentSlug?: string; +} diff --git a/src/comments/comments.controller.ts b/src/comments/comments.controller.ts new file mode 100644 index 0000000..679a464 --- /dev/null +++ b/src/comments/comments.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CommentsService } from './comments.service'; +import { + CreatePublicCommentDto, + ListCommentsDto, + ListPublicCommentsDto, + UpdateCommentApprovalDto, +} from './dto/comment.dto'; + +@Controller('tenants/:host/comments') +export class PublicCommentsController { + constructor(private readonly service: CommentsService) {} + + @Post() + create(@Param('host') host: string, @Body() dto: CreatePublicCommentDto) { + return this.service.createPublic(host, dto); + } + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicCommentsDto) { + return this.service.listPublic(host, query); + } +} + +@Controller('businesses/:businessId/comments') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class CommentsController { + constructor(private readonly service: CommentsService) {} + + @Get() + @RequireBusinessPermission('comments.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListCommentsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.listAdmin(businessId, query, user); + } + + @Patch(':commentId') + @RequireBusinessPermission('comments.approve') + updateApproval( + @Param('businessId') businessId: string, + @Param('commentId') commentId: string, + @Body() dto: UpdateCommentApprovalDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateApproval(businessId, commentId, dto, user); + } + + @Delete(':commentId') + @RequireBusinessPermission('comments.delete') + remove( + @Param('businessId') businessId: string, + @Param('commentId') commentId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, commentId, user); + } +} diff --git a/src/comments/comments.module.ts b/src/comments/comments.module.ts new file mode 100644 index 0000000..7f973d5 --- /dev/null +++ b/src/comments/comments.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { CommentsController, PublicCommentsController } from './comments.controller'; +import { CommentsService } from './comments.service'; + +@Module({ + imports: [AuthModule, BusinessSettingsModule, TenantModule], + controllers: [PublicCommentsController, CommentsController], + providers: [CommentsService], +}) +export class CommentsModule {} diff --git a/src/comments/comments.service.ts b/src/comments/comments.service.ts new file mode 100644 index 0000000..7591139 --- /dev/null +++ b/src/comments/comments.service.ts @@ -0,0 +1,258 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { BusinessSettingsService } from '../business-settings/business-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreatePublicCommentDto, + ListCommentsDto, + ListPublicCommentsDto, + UpdateCommentApprovalDto, +} from './dto/comment.dto'; + +type CommentRecord = Prisma.CommentGetPayload<{ + include: { approver: true }; +}>; + +@Injectable() +export class CommentsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + private readonly businessSettings: BusinessSettingsService, + ) {} + + async createPublic(host: string, dto: CreatePublicCommentDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const entityId = BigInt(dto.entityId); + + await this.assertPublishedEntityExists(businessId, dto.entityType, entityId); + + const autoApprove = await this.businessSettings.isCommentsAutoApprove(businessId); + const approvedAt = autoApprove ? new Date() : null; + + const created = await this.prisma.comment.create({ + data: { + businessId, + entityType: dto.entityType, + entityId, + authorName: dto.authorName.trim(), + authorEmail: dto.authorEmail?.trim() || null, + text: dto.text.trim(), + isApproved: autoApprove, + approvedAt, + }, + include: { approver: true }, + }); + + return { + comment: this.serialize(created), + message: autoApprove + ? 'Comment submitted and is approved' + : 'Comment submitted and is pending approval', + }; + } + + async listPublic(host: string, query: ListPublicCommentsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const entityId = BigInt(query.entityId); + + await this.assertPublishedEntityExists(businessId, query.entityType, entityId); + + const items = await this.prisma.comment.findMany({ + where: { + businessId, + entityType: query.entityType, + entityId, + isApproved: true, + }, + orderBy: { createdAt: 'desc' }, + include: { approver: true }, + }); + + return { items: items.map((item) => this.serialize(item)) }; + } + + async listAdmin(businessIdRaw: string, query: ListCommentsDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'comments.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.CommentWhereInput = { + businessId, + ...(query.entityType ? { entityType: query.entityType } : {}), + ...(query.entityId ? { entityId: BigInt(query.entityId) } : {}), + ...(query.isApproved !== undefined ? { isApproved: query.isApproved } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.comment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: { approver: true }, + }), + this.prisma.comment.count({ where }), + ]); + + return { + items: items.map((item) => this.serialize(item)), + total, + page, + pageSize, + }; + } + + async updateApproval( + businessIdRaw: string, + commentIdRaw: string, + dto: UpdateCommentApprovalDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const commentId = BigInt(commentIdRaw); + await this.assertPermission(businessId, actor.id, 'comments.approve'); + + const existing = await this.prisma.comment.findFirst({ + where: { id: commentId, businessId }, + include: { approver: true }, + }); + + if (!existing) { + throw new NotFoundException('Comment not found'); + } + + const updated = await this.prisma.comment.update({ + where: { id: commentId }, + data: { + isApproved: dto.isApproved, + approvedAt: dto.isApproved ? new Date() : null, + approvedBy: dto.isApproved ? actor.id : null, + }, + include: { approver: true }, + }); + + return { comment: this.serialize(updated) }; + } + + async remove(businessIdRaw: string, commentIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const commentId = BigInt(commentIdRaw); + await this.assertPermission(businessId, actor.id, 'comments.delete'); + + const existing = await this.prisma.comment.findFirst({ + where: { id: commentId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Comment not found'); + } + + await this.prisma.comment.delete({ where: { id: commentId } }); + + return { success: true }; + } + + private async assertPublishedEntityExists( + businessId: bigint, + entityType: MediaEntityType, + entityId: bigint, + ) { + if (entityType === MediaEntityType.product) { + const product = await this.prisma.product.findFirst({ + where: { + id: entityId, + businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + return; + } + + if (entityType === MediaEntityType.blog) { + const blog = await this.prisma.blogs.findFirst({ + where: { + id: entityId, + business_id: businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!blog) { + throw new NotFoundException('Blog post not found'); + } + return; + } + + const rows = await this.prisma.$queryRaw<{ id: bigint }[]>` + SELECT id FROM portfolios + WHERE id = ${entityId} + AND business_id = ${businessId} + AND status = 'published'::content_status + LIMIT 1 + `; + + if (rows.length === 0) { + throw new NotFoundException('Portfolio item not found'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } + + private serialize(comment: CommentRecord) { + return { + id: comment.id.toString(), + businessId: comment.businessId.toString(), + entityType: comment.entityType, + entityId: comment.entityId.toString(), + authorName: comment.authorName, + authorEmail: comment.authorEmail, + text: comment.text, + isApproved: comment.isApproved, + approvedAt: comment.approvedAt, + approvedBy: comment.approvedBy?.toString() ?? null, + approver: comment.approver + ? { + id: comment.approver.id.toString(), + firstName: comment.approver.firstName, + lastName: comment.approver.lastName, + } + : null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + } +} diff --git a/src/comments/dto/comment.dto.ts b/src/comments/dto/comment.dto.ts new file mode 100644 index 0000000..07b34d8 --- /dev/null +++ b/src/comments/dto/comment.dto.ts @@ -0,0 +1,79 @@ +import { MediaEntityType } from '@prisma/client'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; + +export class CreatePublicCommentDto { + @IsEnum(MediaEntityType) + entityType!: MediaEntityType; + + @IsString() + @MinLength(1) + entityId!: string; + + @IsString() + @MinLength(2) + authorName!: string; + + @IsOptional() + @IsEmail() + authorEmail?: string; + + @IsString() + @MinLength(1) + text!: string; +} + +export class ListPublicCommentsDto { + @IsEnum(MediaEntityType) + entityType!: MediaEntityType; + + @IsString() + @MinLength(1) + entityId!: string; +} + +export class ListCommentsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(MediaEntityType) + entityType?: MediaEntityType; + + @IsOptional() + @IsString() + entityId?: string; + + /** Filter by approval: true = approved, false = pending, omit = all */ + @IsOptional() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + isApproved?: boolean; +} + +export class UpdateCommentApprovalDto { + @IsBoolean() + isApproved!: boolean; +} diff --git a/src/common/ai-provider.util.ts b/src/common/ai-provider.util.ts new file mode 100644 index 0000000..c47f6ff --- /dev/null +++ b/src/common/ai-provider.util.ts @@ -0,0 +1,83 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export type AiProviderConfig = { + apiKey: string; + baseUrl: string; + model: string; +}; + +export function resolveAiProvider(config: ConfigService): AiProviderConfig { + const configured = config.get('AI_PROVIDER')?.trim().toLowerCase(); + const groqKey = config.get('GROQ_API_KEY')?.trim(); + const openAiKey = config.get('OPENAI_API_KEY')?.trim(); + + const useGroq = + configured === 'groq' || (!configured && !!groqKey) || (!openAiKey && !!groqKey); + + if (useGroq) { + if (!groqKey) { + throw new ServiceUnavailableException( + 'AI is not configured. Set GROQ_API_KEY on the server.', + ); + } + + return { + apiKey: groqKey, + baseUrl: 'https://api.groq.com/openai/v1', + model: config.get('GROQ_MODEL')?.trim() || 'llama-3.3-70b-versatile', + }; + } + + if (!openAiKey) { + throw new ServiceUnavailableException( + 'AI is not configured. Set GROQ_API_KEY or OPENAI_API_KEY on the server.', + ); + } + + return { + apiKey: openAiKey, + baseUrl: 'https://api.openai.com/v1', + model: config.get('OPENAI_MODEL')?.trim() || 'gpt-4o-mini', + }; +} + +export async function requestAiJsonCompletion( + provider: AiProviderConfig, + systemPrompt: string, + userPrompt: string, + temperature = 0.4, +): Promise { + const response = await fetch(`${provider.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${provider.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: provider.model, + temperature, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`AI provider error (${response.status}): ${detail.slice(0, 240)}`); + } + + const payload = (await response.json()) as { + choices?: { message?: { content?: string } }[]; + }; + + const content = payload.choices?.[0]?.message?.content; + if (!content) { + throw new Error('AI returned an empty response'); + } + + return content; +} diff --git a/src/common/interceptors/bigint-serializer.interceptor.ts b/src/common/interceptors/bigint-serializer.interceptor.ts new file mode 100644 index 0000000..5d3a6bd --- /dev/null +++ b/src/common/interceptors/bigint-serializer.interceptor.ts @@ -0,0 +1,36 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, map } from 'rxjs'; + +function serializeBigInt(value: unknown): unknown { + if (typeof value === 'bigint') { + return Number(value); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value.map(serializeBigInt); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [key, serializeBigInt(val)]), + ); + } + + return value; +} + +@Injectable() +export class BigIntSerializerInterceptor implements NestInterceptor { + intercept(_context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe(map((data) => serializeBigInt(data))); + } +} diff --git a/src/contact-submissions/contact-submissions.controller.ts b/src/contact-submissions/contact-submissions.controller.ts new file mode 100644 index 0000000..07cf19a --- /dev/null +++ b/src/contact-submissions/contact-submissions.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ContactSubmissionsService } from './contact-submissions.service'; +import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto'; +import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto'; + +@Controller('tenants/:host/contact-submissions') +export class PublicContactSubmissionsController { + constructor(private readonly service: ContactSubmissionsService) {} + + @Post() + create(@Param('host') host: string, @Body() dto: CreateContactSubmissionDto) { + return this.service.createPublic(host, dto); + } +} + +@Controller('businesses/:businessId/contact-submissions') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class ContactSubmissionsController { + constructor(private readonly service: ContactSubmissionsService) {} + + @Get() + @RequireBusinessPermission('business.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListContactSubmissionsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':submissionId') + @RequireBusinessPermission('business.read') + getOne( + @Param('businessId') businessId: string, + @Param('submissionId') submissionId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, submissionId, user); + } +} diff --git a/src/contact-submissions/contact-submissions.module.ts b/src/contact-submissions/contact-submissions.module.ts new file mode 100644 index 0000000..94ef85b --- /dev/null +++ b/src/contact-submissions/contact-submissions.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + ContactSubmissionsController, + PublicContactSubmissionsController, +} from './contact-submissions.controller'; +import { ContactSubmissionsService } from './contact-submissions.service'; + +@Module({ + imports: [AuthModule, TenantModule], + controllers: [ContactSubmissionsController, PublicContactSubmissionsController], + providers: [ContactSubmissionsService], +}) +export class ContactSubmissionsModule {} diff --git a/src/contact-submissions/contact-submissions.service.ts b/src/contact-submissions/contact-submissions.service.ts new file mode 100644 index 0000000..fbdd876 --- /dev/null +++ b/src/contact-submissions/contact-submissions.service.ts @@ -0,0 +1,155 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { CreateContactSubmissionDto } from './dto/create-contact-submission.dto'; +import { ListContactSubmissionsDto } from './dto/list-contact-submissions.dto'; + +type ContactSubmissionRow = { + id: bigint; + title: string; + name: string; + email: string | null; + cellNumber: string | null; + text: string; + createdAt: Date; +}; + +@Injectable() +export class ContactSubmissionsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async createPublic(host: string, dto: CreateContactSubmissionDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + + const created = await this.prisma.contactSubmission.create({ + data: { + businessId: business.id, + title: dto.title.trim(), + name: dto.name.trim(), + email: dto.email?.trim() || null, + cellNumber: dto.cellNumber?.trim() || null, + text: dto.text.trim(), + }, + }); + + return { + submission: this.serialize(created), + message: 'Contact form submitted successfully', + }; + } + + async list( + businessIdRaw: string, + query: ListContactSubmissionsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.read'); + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); + const skip = (page - 1) * pageSize; + const qLike = query.q?.trim() ? `%${query.q.trim()}%` : null; + + const where = Prisma.sql` + WHERE cs.business_id = ${businessId} + ${qLike ? Prisma.sql` + AND ( + cs.title ILIKE ${qLike} + OR cs.name ILIKE ${qLike} + OR cs.email ILIKE ${qLike} + OR cs.cell_number ILIKE ${qLike} + OR cs.text ILIKE ${qLike} + ) + ` : Prisma.empty} + `; + + const [items, totalRow] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT + cs.id AS "id", + cs.title AS "title", + cs.name AS "name", + cs.email AS "email", + cs.cell_number AS "cellNumber", + cs.text AS "text", + cs.created_at AS "createdAt" + FROM contact_submissions cs + ${where} + ORDER BY cs.created_at DESC + LIMIT ${pageSize} OFFSET ${skip} + `), + this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` + SELECT COUNT(*)::int AS "total" + FROM contact_submissions cs + ${where} + `), + ]); + + return { + items: items.map((row) => this.serialize(row)), + total: totalRow[0]?.total ?? 0, + page, + pageSize, + }; + } + + async getOne(businessIdRaw: string, submissionIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const submissionId = BigInt(submissionIdRaw); + await this.assertPermission(businessId, actor.id, 'business.read'); + + const submission = await this.prisma.contactSubmission.findFirst({ + where: { id: submissionId, businessId }, + }); + + if (!submission) { + throw new NotFoundException('Contact submission not found'); + } + + return { submission: this.serialize(submission) }; + } + + private serialize(row: ContactSubmissionRow | { + id: bigint; + title: string; + name: string; + email: string | null; + cellNumber: string | null; + text: string; + createdAt: Date; + }) { + return { + id: row.id.toString(), + title: row.title, + name: row.name, + email: row.email, + cellNumber: row.cellNumber, + text: row.text, + createdAt: row.createdAt.toISOString(), + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } +} diff --git a/src/contact-submissions/dto/create-contact-submission.dto.ts b/src/contact-submissions/dto/create-contact-submission.dto.ts new file mode 100644 index 0000000..3be624b --- /dev/null +++ b/src/contact-submissions/dto/create-contact-submission.dto.ts @@ -0,0 +1,27 @@ +import { IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateContactSubmissionDto { + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @IsString() + @MinLength(1) + @MaxLength(255) + name!: string; + + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + cellNumber?: string; + + @IsString() + @MinLength(1) + text!: string; +} diff --git a/src/contact-submissions/dto/list-contact-submissions.dto.ts b/src/contact-submissions/dto/list-contact-submissions.dto.ts new file mode 100644 index 0000000..4119b3e --- /dev/null +++ b/src/contact-submissions/dto/list-contact-submissions.dto.ts @@ -0,0 +1,20 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class ListContactSubmissionsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + q?: string; +} diff --git a/src/customers/customers.controller.ts b/src/customers/customers.controller.ts new file mode 100644 index 0000000..407bfbf --- /dev/null +++ b/src/customers/customers.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CustomersService } from './customers.service'; +import { CreateCustomerDto } from './dto/create-customer.dto'; +import { ListCustomersDto } from './dto/list-customers.dto'; +import { SearchCustomersDto } from './dto/search-customers.dto'; +import { UpdateCustomerDto } from './dto/update-customer.dto'; + +@Controller('businesses/:businessId/customers') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class CustomersController { + constructor(private readonly service: CustomersService) {} + + @Get() + @RequireBusinessPermission('orders.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListCustomersDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get('search') + @RequireBusinessPermission('orders.create') + search( + @Param('businessId') businessId: string, + @Query() query: SearchCustomersDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.search(businessId, query, user); + } + + @Post() + @RequireBusinessPermission('orders.create') + create( + @Param('businessId') businessId: string, + @Body() body: CreateCustomerDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, body, user); + } + + @Patch(':userId') + @RequireBusinessPermission('orders.update') + update( + @Param('businessId') businessId: string, + @Param('userId') userId: string, + @Body() body: UpdateCustomerDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, userId, body, user); + } + + @Delete(':userId') + @RequireBusinessPermission('orders.update') + remove( + @Param('businessId') businessId: string, + @Param('userId') userId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, userId, user); + } +} diff --git a/src/customers/customers.module.ts b/src/customers/customers.module.ts new file mode 100644 index 0000000..0be9cb6 --- /dev/null +++ b/src/customers/customers.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CustomersController } from './customers.controller'; +import { CustomersService } from './customers.service'; + +@Module({ + imports: [AuthModule], + controllers: [CustomersController], + providers: [CustomersService], +}) +export class CustomersModule {} diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts new file mode 100644 index 0000000..efdaa0b --- /dev/null +++ b/src/customers/customers.service.ts @@ -0,0 +1,402 @@ +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateCustomerDto } from './dto/create-customer.dto'; +import { ListCustomersDto } from './dto/list-customers.dto'; +import { SearchCustomersDto } from './dto/search-customers.dto'; +import { UpdateCustomerDto } from './dto/update-customer.dto'; + +type CustomerListRow = { + id: bigint; + cellNumber: string; + firstName: string | null; + lastName: string | null; + email: string | null; + createdAt: Date; + isEnabled: boolean; +}; + +@Injectable() +export class CustomersService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list( + businessIdRaw: string, + query: ListCustomersDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.read'); + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100); + const skip = (page - 1) * pageSize; + + const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null; + const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null; + + const where = Prisma.sql` + WHERE bc.business_id = ${businessId} + ${nameLike ? Prisma.sql` + AND ( + u.first_name ILIKE ${nameLike} + OR u.last_name ILIKE ${nameLike} + OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} + ) + ` : Prisma.empty} + ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} + `; + + const [items, totalRow] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT + u.id AS "id", + u.cell_number AS "cellNumber", + u.first_name AS "firstName", + u.last_name AS "lastName", + u.email AS "email", + bc.created_at AS "createdAt", + bc.is_enabled AS "isEnabled" + FROM business_customers bc + JOIN users u ON u.id = bc.user_id + ${where} + ORDER BY bc.created_at DESC + LIMIT ${pageSize} OFFSET ${skip} + `), + this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` + SELECT COUNT(*)::int AS "total" + FROM business_customers bc + JOIN users u ON u.id = bc.user_id + ${where} + `), + ]); + + return { + items: items.map((user) => ({ + id: user.id.toString(), + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + label: this.formatLabel(user), + createdAt: user.createdAt.toISOString(), + isEnabled: user.isEnabled, + })), + total: totalRow[0]?.total ?? 0, + page, + pageSize, + }; + } + + async search( + businessIdRaw: string, + query: SearchCustomersDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.create'); + + const q = query.q.trim(); + const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const like = `%${q}%`; + + const items = await this.prisma.$queryRaw< + { + id: bigint; + cellNumber: string; + firstName: string | null; + lastName: string | null; + email: string | null; + }[] + >(Prisma.sql` + SELECT + u.id AS "id", + u.cell_number AS "cellNumber", + u.first_name AS "firstName", + u.last_name AS "lastName", + u.email AS "email" + FROM business_customers bc + JOIN users u ON u.id = bc.user_id + WHERE bc.business_id = ${businessId} + AND bc.is_enabled = TRUE + AND u.is_active = TRUE + AND ( + u.cell_number ILIKE ${like} + OR u.first_name ILIKE ${like} + OR u.last_name ILIKE ${like} + OR u.email ILIKE ${like} + OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like} + ) + ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST + LIMIT ${limit} + `); + + return { + items: items.map((user) => ({ + id: user.id.toString(), + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + label: this.formatLabel(user), + })), + }; + } + + async create( + businessIdRaw: string, + dto: CreateCustomerDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.create'); + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + + if (!business?.isActive) { + throw new NotFoundException('Business not found'); + } + + const customerRole = await this.prisma.role.findUnique({ + where: { slug: 'customer' }, + }); + + if (!customerRole) { + throw new Error('Customer role is missing. Run database migrations first.'); + } + + const existingUser = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + include: { + businessCustomers: { where: { businessId } }, + }, + }); + + if (existingUser?.businessCustomers.length) { + throw new ConflictException('User is already a customer of this business'); + } + + if (existingUser) { + const staffMembership = await this.prisma.businessUser.findUnique({ + where: { + businessId_userId: { businessId, userId: existingUser.id }, + }, + }); + if (staffMembership) { + throw new ConflictException('User is already staff of this business'); + } + } + + if (!existingUser && !dto.password) { + throw new BadRequestException('password is required for new users'); + } + + const passwordHash = existingUser + ? existingUser.passwordHash + : await bcrypt.hash(dto.password!, 10); + + const membership = await this.prisma.$transaction(async (tx) => { + const account = + existingUser ?? + (await tx.user.create({ + data: { + cellNumber: dto.cellNumber, + passwordHash, + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + cellVerifiedAt: new Date(), + }, + })); + + if (existingUser && !existingUser.cellVerifiedAt) { + await tx.user.update({ + where: { id: account.id }, + data: { cellVerifiedAt: new Date() }, + }); + } + + const link = await tx.businessCustomer.create({ + data: { + businessId, + userId: account.id, + }, + }); + + const hasCustomerRole = await tx.userRole.findUnique({ + where: { + userId_roleId: { + userId: account.id, + roleId: customerRole.id, + }, + }, + }); + + if (!hasCustomerRole) { + await tx.userRole.create({ + data: { + userId: account.id, + roleId: customerRole.id, + }, + }); + } + + return { account, link }; + }); + + const user = membership.account; + const verifiedAt = user.cellVerifiedAt ?? new Date(); + + return { + id: user.id.toString(), + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + label: this.formatLabel(user), + createdAt: membership.link.createdAt.toISOString(), + isEnabled: membership.link.isEnabled, + isVerified: verifiedAt !== null, + role: 'customer', + }; + } + + async update( + businessIdRaw: string, + userIdRaw: string, + dto: UpdateCustomerDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const userId = BigInt(userIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.update'); + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + include: { user: true }, + }); + + if (!membership) { + throw new NotFoundException('Customer not found for this business'); + } + + if (dto.isEnabled !== undefined) { + await this.prisma.businessCustomer.update({ + where: { id: membership.id }, + data: { isEnabled: dto.isEnabled }, + }); + } + + const hasProfileUpdate = + dto.firstName !== undefined || + dto.lastName !== undefined || + dto.email !== undefined || + dto.cellNumber !== undefined; + + if (hasProfileUpdate) { + const user = membership.user; + + if (dto.cellNumber && dto.cellNumber !== user.cellNumber) { + const existing = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + }); + if (existing && existing.id !== userId) { + throw new BadRequestException('Cell number is already in use'); + } + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { + firstName: dto.firstName?.trim(), + lastName: dto.lastName?.trim(), + email: dto.email !== undefined ? dto.email.trim() || null : undefined, + cellNumber: dto.cellNumber?.trim(), + }, + }); + } + + const refreshed = await this.prisma.businessCustomer.findUnique({ + where: { id: membership.id }, + include: { user: true }, + }); + + if (!refreshed) { + throw new NotFoundException('Customer not found for this business'); + } + + const user = refreshed.user; + return { + id: user.id.toString(), + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + label: this.formatLabel(user), + isEnabled: refreshed.isEnabled, + }; + } + + async remove(businessIdRaw: string, userIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const userId = BigInt(userIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.update'); + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + }); + + if (!membership) { + throw new NotFoundException('Customer not found for this business'); + } + + await this.prisma.businessCustomer.delete({ + where: { id: membership.id }, + }); + + return { success: true }; + } + + private formatLabel(user: { + firstName: string | null; + lastName: string | null; + cellNumber: string; + email: string | null; + }) { + const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (name) { + return `${name} · ${user.cellNumber}`; + } + return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/customers/dto/create-customer.dto.ts b/src/customers/dto/create-customer.dto.ts new file mode 100644 index 0000000..2571794 --- /dev/null +++ b/src/customers/dto/create-customer.dto.ts @@ -0,0 +1,32 @@ +import { + IsEmail, + IsOptional, + IsString, + Matches, + MinLength, +} from 'class-validator'; + +export class CreateCustomerDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; + + @IsOptional() + @IsString() + @MinLength(8) + password?: string; + + @IsString() + @MinLength(2) + firstName!: string; + + @IsString() + @MinLength(2) + lastName!: string; + + @IsOptional() + @IsEmail() + email?: string; +} diff --git a/src/customers/dto/list-customers.dto.ts b/src/customers/dto/list-customers.dto.ts new file mode 100644 index 0000000..3fcfd82 --- /dev/null +++ b/src/customers/dto/list-customers.dto.ts @@ -0,0 +1,24 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Min } from 'class-validator'; + +export class ListCustomersDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + cellNumber?: string; +} diff --git a/src/customers/dto/search-customers.dto.ts b/src/customers/dto/search-customers.dto.ts new file mode 100644 index 0000000..4cc1b37 --- /dev/null +++ b/src/customers/dto/search-customers.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SearchCustomersDto { + @IsString() + @MinLength(2, { message: 'q must be at least 2 characters' }) + q!: string; + + @IsOptional() + @Type(() => Number) + limit?: number = 20; +} diff --git a/src/customers/dto/update-customer.dto.ts b/src/customers/dto/update-customer.dto.ts new file mode 100644 index 0000000..9402d03 --- /dev/null +++ b/src/customers/dto/update-customer.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateCustomerDto { + @IsOptional() + @IsBoolean() + isEnabled?: boolean; + + @IsOptional() + @IsString() + @MinLength(1) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + lastName?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + @MinLength(10) + cellNumber?: string; +} diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts new file mode 100644 index 0000000..8690062 --- /dev/null +++ b/src/domain-admin/domain-admin.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Delete, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { DisableDomainDto } from './dto/disable-domain.dto'; +import { ListDomainsDto } from './dto/list-domains.dto'; +import { ToggleSslDto } from './dto/toggle-ssl.dto'; +import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto'; +import { DomainAdminService } from './domain-admin.service'; + +@Controller('domains') +export class DomainAdminController { + constructor(private readonly service: DomainAdminService) {} + + @Get() + @UseGuards(JwtAuthGuard) + list(@Query() query: ListDomainsDto, @CurrentUser() user: AuthUser) { + return this.service.list(query, user); + } + + @Patch(':domainId') + @UseGuards(JwtAuthGuard) + update( + @Param('domainId') domainId: string, + @Body() dto: UpdateDomainAdminDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(domainId, dto, user); + } + + @Patch(':domainId/disable') + @UseGuards(JwtAuthGuard) + disable( + @Param('domainId') domainId: string, + @Body() dto: DisableDomainDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.disable(domainId, dto, user); + } + + @Patch(':domainId/ssl') + @UseGuards(JwtAuthGuard) + toggleSsl( + @Param('domainId') domainId: string, + @Body() dto: ToggleSslDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.toggleSsl(domainId, dto, user); + } + + @Delete(':domainId') + @UseGuards(JwtAuthGuard) + remove(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) { + return this.service.remove(domainId, user); + } +} diff --git a/src/domain-admin/domain-admin.module.ts b/src/domain-admin/domain-admin.module.ts new file mode 100644 index 0000000..6ded5b8 --- /dev/null +++ b/src/domain-admin/domain-admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { DomainAdminController } from './domain-admin.controller'; +import { DomainAdminService } from './domain-admin.service'; + +@Module({ + imports: [AuthModule], + controllers: [DomainAdminController], + providers: [DomainAdminService], +}) +export class DomainAdminModule {} diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts new file mode 100644 index 0000000..c6a9ca5 --- /dev/null +++ b/src/domain-admin/domain-admin.service.ts @@ -0,0 +1,150 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { DisableDomainDto } from './dto/disable-domain.dto'; +import { ListDomainsDto } from './dto/list-domains.dto'; +import { ToggleSslDto } from './dto/toggle-ssl.dto'; +import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto'; + +type DomainRow = { + id: bigint; + host: string; + businessId: bigint; + businessName: string; + sslEnabled: boolean; + isActive: boolean; + expiresAt: Date | null; + createdAt: Date; +}; + +@Injectable() +export class DomainAdminService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + private async assertSuperAdmin(actor: AuthUser) { + if (!(await this.permissions.isSuperAdmin(actor.id))) { + throw new ForbiddenException('Super admin access required'); + } + } + + async list(query: ListDomainsDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 10; + const skip = (page - 1) * pageSize; + const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null; + + const where = Prisma.sql` + WHERE 1=1 + ${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty} + `; + + const [items, totalRow] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT + d.id AS "id", + d.host AS "host", + d.business_id AS "businessId", + b.name AS "businessName", + d.ssl_enabled AS "sslEnabled", + d.is_active AS "isActive", + d.expires_at AS "expiresAt", + d.created_at AS "createdAt" + FROM domains d + JOIN businesses b ON b.id = d.business_id + ${where} + ORDER BY d.created_at DESC + LIMIT ${pageSize} OFFSET ${skip} + `), + this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` + SELECT COUNT(*)::int AS "total" + FROM domains d + ${where} + `), + ]); + + return { items, total: totalRow[0]?.total ?? 0, page, pageSize }; + } + + async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const domainId = BigInt(domainIdRaw); + const domain = await this.prisma.domain.findUnique({ where: { id: domainId } }); + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + if (dto.host) { + const host = dto.host.trim(); + const existing = await this.prisma.domain.findUnique({ where: { host } }); + if (existing && existing.id !== domainId) { + throw new ConflictException('Domain host is already taken'); + } + } + + return this.prisma.domain.update({ + where: { id: domainId }, + data: { + host: dto.host?.trim(), + expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined, + }, + }); + } + + async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const domainId = BigInt(domainIdRaw); + const domain = await this.prisma.domain.findUnique({ where: { id: domainId } }); + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + return this.prisma.domain.update({ + where: { id: domainId }, + data: { isActive: dto.isActive }, + }); + } + + async toggleSsl(domainIdRaw: string, dto: ToggleSslDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const domainId = BigInt(domainIdRaw); + const domain = await this.prisma.domain.findUnique({ where: { id: domainId } }); + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + return this.prisma.domain.update({ + where: { id: domainId }, + data: { sslEnabled: dto.sslEnabled }, + }); + } + + async remove(domainIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const domainId = BigInt(domainIdRaw); + const domain = await this.prisma.domain.findUnique({ where: { id: domainId } }); + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + await this.prisma.domain.delete({ where: { id: domainId } }); + + return { message: 'Domain removed' }; + } +} diff --git a/src/domain-admin/dto/disable-domain.dto.ts b/src/domain-admin/dto/disable-domain.dto.ts new file mode 100644 index 0000000..c1df681 --- /dev/null +++ b/src/domain-admin/dto/disable-domain.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class DisableDomainDto { + @IsBoolean() + isActive!: boolean; +} diff --git a/src/domain-admin/dto/list-domains.dto.ts b/src/domain-admin/dto/list-domains.dto.ts new file mode 100644 index 0000000..5c6138e --- /dev/null +++ b/src/domain-admin/dto/list-domains.dto.ts @@ -0,0 +1,21 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListDomainsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(5) + @Max(50) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; +} diff --git a/src/domain-admin/dto/toggle-ssl.dto.ts b/src/domain-admin/dto/toggle-ssl.dto.ts new file mode 100644 index 0000000..be87071 --- /dev/null +++ b/src/domain-admin/dto/toggle-ssl.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class ToggleSslDto { + @IsBoolean() + sslEnabled!: boolean; +} diff --git a/src/domain-admin/dto/update-domain-admin.dto.ts b/src/domain-admin/dto/update-domain-admin.dto.ts new file mode 100644 index 0000000..6f8c163 --- /dev/null +++ b/src/domain-admin/dto/update-domain-admin.dto.ts @@ -0,0 +1,12 @@ +import { IsDateString, IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateDomainAdminDto { + @IsOptional() + @IsString() + @MinLength(1) + host?: string; + + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/src/expert-reviews/dto/expert-review.dto.ts b/src/expert-reviews/dto/expert-review.dto.ts new file mode 100644 index 0000000..452f5cf --- /dev/null +++ b/src/expert-reviews/dto/expert-review.dto.ts @@ -0,0 +1,100 @@ +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsEmail, + IsInt, + IsOptional, + IsString, + Max, + Min, + MinLength, +} from 'class-validator'; + +function trimStringArray({ value }: { value: unknown }) { + if (!Array.isArray(value)) { + return value; + } + + return value + .map((item) => (typeof item === 'string' ? item.trim() : item)) + .filter((item) => typeof item === 'string' && item.length > 0); +} + +export class CreatePublicExpertReviewDto { + @IsString() + @MinLength(1) + productId!: string; + + @IsString() + @MinLength(2) + authorName!: string; + + @IsOptional() + @IsEmail() + authorEmail?: string; + + @Type(() => Number) + @IsInt() + @Min(1) + @Max(10) + rate!: number; + + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(0) + @ArrayMaxSize(50) + @Transform(trimStringArray) + positivePoints!: string[]; + + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(0) + @ArrayMaxSize(50) + @Transform(trimStringArray) + negativePoints!: string[]; + + @IsString() + @MinLength(1) + text!: string; +} + +export class ListPublicExpertReviewsDto { + @IsString() + @MinLength(1) + productId!: string; +} + +export class ListExpertReviewsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + isApproved?: boolean; +} + +export class UpdateExpertReviewApprovalDto { + @IsBoolean() + isApproved!: boolean; +} diff --git a/src/expert-reviews/expert-reviews.controller.ts b/src/expert-reviews/expert-reviews.controller.ts new file mode 100644 index 0000000..62182db --- /dev/null +++ b/src/expert-reviews/expert-reviews.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreatePublicExpertReviewDto, + ListExpertReviewsDto, + ListPublicExpertReviewsDto, + UpdateExpertReviewApprovalDto, +} from './dto/expert-review.dto'; +import { ExpertReviewsService } from './expert-reviews.service'; + +@Controller('tenants/:host/expert-reviews') +export class PublicExpertReviewsController { + constructor(private readonly service: ExpertReviewsService) {} + + @Post() + create(@Param('host') host: string, @Body() dto: CreatePublicExpertReviewDto) { + return this.service.createPublic(host, dto); + } + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicExpertReviewsDto) { + return this.service.listPublic(host, query); + } +} + +@Controller('businesses/:businessId/expert-reviews') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class ExpertReviewsController { + constructor(private readonly service: ExpertReviewsService) {} + + @Get() + @RequireBusinessPermission('expert_reviews.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListExpertReviewsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.listAdmin(businessId, query, user); + } + + @Patch(':reviewId') + @RequireBusinessPermission('expert_reviews.approve') + updateApproval( + @Param('businessId') businessId: string, + @Param('reviewId') reviewId: string, + @Body() dto: UpdateExpertReviewApprovalDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateApproval(businessId, reviewId, dto, user); + } + + @Delete(':reviewId') + @RequireBusinessPermission('expert_reviews.delete') + remove( + @Param('businessId') businessId: string, + @Param('reviewId') reviewId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, reviewId, user); + } +} diff --git a/src/expert-reviews/expert-reviews.module.ts b/src/expert-reviews/expert-reviews.module.ts new file mode 100644 index 0000000..45163d3 --- /dev/null +++ b/src/expert-reviews/expert-reviews.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + ExpertReviewsController, + PublicExpertReviewsController, +} from './expert-reviews.controller'; +import { ExpertReviewsService } from './expert-reviews.service'; + +@Module({ + imports: [AuthModule, BusinessSettingsModule, TenantModule], + controllers: [PublicExpertReviewsController, ExpertReviewsController], + providers: [ExpertReviewsService], +}) +export class ExpertReviewsModule {} diff --git a/src/expert-reviews/expert-reviews.service.ts b/src/expert-reviews/expert-reviews.service.ts new file mode 100644 index 0000000..3bce528 --- /dev/null +++ b/src/expert-reviews/expert-reviews.service.ts @@ -0,0 +1,242 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { BusinessSettingsService } from '../business-settings/business-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreatePublicExpertReviewDto, + ListExpertReviewsDto, + ListPublicExpertReviewsDto, + UpdateExpertReviewApprovalDto, +} from './dto/expert-review.dto'; + +type ExpertReviewRecord = Prisma.ExpertReviewGetPayload<{ + include: { approver: true; product: { select: { id: true; title: true; slug: true } } }; +}>; + +@Injectable() +export class ExpertReviewsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + private readonly businessSettings: BusinessSettingsService, + ) {} + + async createPublic(host: string, dto: CreatePublicExpertReviewDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const productId = BigInt(dto.productId); + + await this.assertPublishedProductExists(businessId, productId); + + const autoApprove = + await this.businessSettings.isExpertReviewsAutoApprove(businessId); + const approvedAt = autoApprove ? new Date() : null; + + const created = await this.prisma.expertReview.create({ + data: { + businessId, + productId, + authorName: dto.authorName.trim(), + authorEmail: dto.authorEmail?.trim() || null, + rate: dto.rate, + positivePoints: dto.positivePoints, + negativePoints: dto.negativePoints, + text: dto.text.trim(), + isApproved: autoApprove, + approvedAt, + }, + include: this.defaultInclude(), + }); + + return { + review: this.serialize(created), + message: autoApprove + ? 'Expert review submitted and is approved' + : 'Expert review submitted and is pending approval', + }; + } + + async listPublic(host: string, query: ListPublicExpertReviewsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const productId = BigInt(query.productId); + + await this.assertPublishedProductExists(businessId, productId); + + const items = await this.prisma.expertReview.findMany({ + where: { + businessId, + productId, + isApproved: true, + }, + orderBy: { createdAt: 'desc' }, + include: this.defaultInclude(), + }); + + return { items: items.map((item) => this.serialize(item)) }; + } + + async listAdmin( + businessIdRaw: string, + query: ListExpertReviewsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'expert_reviews.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.ExpertReviewWhereInput = { + businessId, + ...(query.productId ? { productId: BigInt(query.productId) } : {}), + ...(query.isApproved !== undefined ? { isApproved: query.isApproved } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.expertReview.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: this.defaultInclude(), + }), + this.prisma.expertReview.count({ where }), + ]); + + return { + items: items.map((item) => this.serialize(item)), + total, + page, + pageSize, + }; + } + + async updateApproval( + businessIdRaw: string, + reviewIdRaw: string, + dto: UpdateExpertReviewApprovalDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const reviewId = BigInt(reviewIdRaw); + await this.assertPermission(businessId, actor.id, 'expert_reviews.approve'); + + const existing = await this.prisma.expertReview.findFirst({ + where: { id: reviewId, businessId }, + include: this.defaultInclude(), + }); + + if (!existing) { + throw new NotFoundException('Expert review not found'); + } + + const updated = await this.prisma.expertReview.update({ + where: { id: reviewId }, + data: { + isApproved: dto.isApproved, + approvedAt: dto.isApproved ? new Date() : null, + approvedBy: dto.isApproved ? actor.id : null, + }, + include: this.defaultInclude(), + }); + + return { review: this.serialize(updated) }; + } + + async remove(businessIdRaw: string, reviewIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const reviewId = BigInt(reviewIdRaw); + await this.assertPermission(businessId, actor.id, 'expert_reviews.delete'); + + const existing = await this.prisma.expertReview.findFirst({ + where: { id: reviewId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Expert review not found'); + } + + await this.prisma.expertReview.delete({ where: { id: reviewId } }); + + return { success: true }; + } + + private defaultInclude() { + return { + approver: true, + product: { select: { id: true, title: true, slug: true } }, + } as const; + } + + private async assertPublishedProductExists(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } + + private serialize(review: ExpertReviewRecord) { + return { + id: review.id.toString(), + businessId: review.businessId.toString(), + productId: review.productId.toString(), + product: { + id: review.product.id.toString(), + title: review.product.title, + slug: review.product.slug, + }, + authorName: review.authorName, + authorEmail: review.authorEmail, + rate: review.rate, + positivePoints: review.positivePoints, + negativePoints: review.negativePoints, + text: review.text, + isApproved: review.isApproved, + approvedAt: review.approvedAt, + approvedBy: review.approvedBy?.toString() ?? null, + approver: review.approver + ? { + id: review.approver.id.toString(), + firstName: review.approver.firstName, + lastName: review.approver.lastName, + } + : null, + createdAt: review.createdAt, + updatedAt: review.updatedAt, + }; + } +} diff --git a/src/favorites/dto/favorite.dto.ts b/src/favorites/dto/favorite.dto.ts new file mode 100644 index 0000000..1a08e5f --- /dev/null +++ b/src/favorites/dto/favorite.dto.ts @@ -0,0 +1,22 @@ +import { IsInt, IsOptional, IsString, Min, MinLength } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ListFavoritesDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} + +export class AddFavoriteDto { + @IsString() + @MinLength(1) + productId!: string; +} diff --git a/src/favorites/favorites.controller.ts b/src/favorites/favorites.controller.ts new file mode 100644 index 0000000..29c8e68 --- /dev/null +++ b/src/favorites/favorites.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AddFavoriteDto, ListFavoritesDto } from './dto/favorite.dto'; +import { FavoritesService } from './favorites.service'; + +@Controller('businesses/:businessId/favorites') +@UseGuards(JwtAuthGuard) +export class FavoritesController { + constructor(private readonly service: FavoritesService) {} + + @Get() + list( + @Param('businessId') businessId: string, + @Query() query: ListFavoritesDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Post() + add( + @Param('businessId') businessId: string, + @Body() dto: AddFavoriteDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.add(businessId, dto, user); + } + + @Delete(':productId') + remove( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, productId, user); + } +} diff --git a/src/favorites/favorites.module.ts b/src/favorites/favorites.module.ts new file mode 100644 index 0000000..6e7dda4 --- /dev/null +++ b/src/favorites/favorites.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { FavoritesController } from './favorites.controller'; +import { FavoritesService } from './favorites.service'; + +@Module({ + imports: [AuthModule], + controllers: [FavoritesController], + providers: [FavoritesService], +}) +export class FavoritesModule {} diff --git a/src/favorites/favorites.service.ts b/src/favorites/favorites.service.ts new file mode 100644 index 0000000..d04a051 --- /dev/null +++ b/src/favorites/favorites.service.ts @@ -0,0 +1,351 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AddFavoriteDto, ListFavoritesDto } from './dto/favorite.dto'; + +const favoriteProductInclude = { + product: { + include: { + featuredMedia: true, + }, + }, +} satisfies Prisma.FavoriteInclude; + +type FavoriteWithProduct = Prisma.FavoriteGetPayload<{ + include: typeof favoriteProductInclude; +}>; + +type StoreSummary = { + variantCount: number; + productTotalStock: number; + displayPrice: number | null; + displayDiscountedPrice: number | null; + showFestival: boolean; +}; + +@Injectable() +export class FavoritesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list( + businessIdRaw: string, + query: ListFavoritesDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); + const skip = (page - 1) * pageSize; + + const where = { + businessId, + userId: actor.id, + }; + + const [items, total] = await Promise.all([ + this.prisma.favorite.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: favoriteProductInclude, + }), + this.prisma.favorite.count({ where }), + ]); + + const productIds = items.map((item) => item.productId); + const galleryProductIds = items + .filter((item) => !item.product.featuredMedia?.publicUrl) + .map((item) => item.productId); + const [storeSummaries, galleryByProduct] = await Promise.all([ + this.loadStoreSummaries(businessId, productIds), + this.loadFirstGalleryUrls(businessId, [...new Set(galleryProductIds)]), + ]); + + return { + items: items.map((item) => + this.serialize( + item, + storeSummaries.get(item.productId.toString()), + galleryByProduct, + ), + ), + total, + page, + pageSize, + }; + } + + async add(businessIdRaw: string, dto: AddFavoriteDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(dto.productId); + await this.assertCustomerAccess(businessId, actor); + await this.assertFavoritableProduct(businessId, productId); + + const existing = await this.prisma.favorite.findUnique({ + where: { + businessId_userId_productId: { + businessId, + userId: actor.id, + productId, + }, + }, + include: favoriteProductInclude, + }); + + if (existing) { + throw new ConflictException('Product is already in favorites'); + } + + const created = await this.prisma.favorite.create({ + data: { + businessId, + userId: actor.id, + productId, + }, + include: favoriteProductInclude, + }); + + const [storeSummaries, galleryByProduct] = await Promise.all([ + this.loadStoreSummaries(businessId, [productId]), + created.product.featuredMedia?.publicUrl + ? Promise.resolve(new Map()) + : this.loadFirstGalleryUrls(businessId, [productId]), + ]); + + return { + favorite: this.serialize( + created, + storeSummaries.get(productId.toString()), + galleryByProduct, + ), + message: 'Product added to favorites', + }; + } + + async remove(businessIdRaw: string, productIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const favorite = await this.prisma.favorite.findUnique({ + where: { + businessId_userId_productId: { + businessId, + userId: actor.id, + productId, + }, + }, + }); + + if (!favorite) { + throw new NotFoundException('Favorite not found'); + } + + await this.prisma.favorite.delete({ + where: { id: favorite.id }, + }); + + return { message: 'Product removed from favorites' }; + } + + private async assertFavoritableProduct(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + if (product.status !== ContentStatus.published) { + throw new BadRequestException('Only published products can be favorited'); + } + } + + private async loadStoreSummaries(businessId: bigint, productIds: bigint[]) { + const summaries = new Map(); + + if (!productIds.length) { + return summaries; + } + + const rows = await this.prisma.storeItemVariant.findMany({ + where: { + businessId, + isActive: true, + storeItem: { + productId: { in: productIds }, + }, + }, + select: { + price: true, + compareAtPrice: true, + stockQuantity: true, + isFestival: true, + rewardPoints: true, + storeItem: { + select: { + productId: true, + }, + }, + }, + }); + + const grouped = new Map(); + for (const row of rows) { + const key = row.storeItem.productId.toString(); + const variants = grouped.get(key) ?? []; + variants.push(row); + grouped.set(key, variants); + } + + for (const [productKey, variants] of grouped) { + let productTotalStock = 0; + let showFestival = false; + let displayPrice: number | null = null; + let displayDiscountedPrice: number | null = null; + let minEffective = Infinity; + + for (const variant of variants) { + productTotalStock += variant.stockQuantity ?? 0; + if (variant.isFestival || (variant.rewardPoints ?? 0) > 0) { + showFestival = true; + } + + const price = variant.price === null ? null : Number(variant.price); + const compareAtPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + const effectivePrice = + price !== null && + compareAtPrice !== null && + compareAtPrice < price + ? compareAtPrice + : price; + + if (effectivePrice === null || effectivePrice >= minEffective) { + continue; + } + + minEffective = effectivePrice; + if ( + price !== null && + compareAtPrice !== null && + compareAtPrice < price + ) { + displayPrice = price; + displayDiscountedPrice = compareAtPrice; + } else { + displayPrice = price; + displayDiscountedPrice = null; + } + } + + if (minEffective === Infinity) { + const first = variants[0]; + displayPrice = first?.price === null ? null : Number(first.price); + displayDiscountedPrice = + first?.compareAtPrice === null ? null : Number(first.compareAtPrice); + } + + summaries.set(productKey, { + variantCount: variants.length, + productTotalStock, + displayPrice, + displayDiscountedPrice, + showFestival, + }); + } + + return summaries; + } + + private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) { + const map = new Map(); + + if (!productIds.length) { + return map; + } + + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: { in: productIds }, + isFeatured: false, + }, + orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }], + include: { media: true }, + }); + + for (const attachment of attachments) { + const key = attachment.entityId.toString(); + if (!map.has(key)) { + map.set(key, attachment.media.publicUrl); + } + } + + return map; + } + + private serialize( + favorite: FavoriteWithProduct, + summary?: StoreSummary, + galleryByProduct: Map = new Map(), + ) { + const product = favorite.product; + const content = this.asRecord(product.content); + const productKey = product.id.toString(); + const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; + + return { + favoriteId: favorite.id.toString(), + productId: productKey, + createdAt: favorite.createdAt, + productTitle: product.title, + productNameFa: (content.nameFa as string | null | undefined) ?? '', + productImage: thumbnailUrl ?? galleryByProduct.get(productKey) ?? null, + productTotalStock: summary?.productTotalStock ?? 0, + variantCount: summary?.variantCount ?? 0, + displayPrice: summary?.displayPrice ?? null, + displayDiscountedPrice: summary?.displayDiscountedPrice ?? null, + showFestival: summary?.showFestival ?? false, + }; + } + + private asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async assertCustomerAccess(businessId: bigint, actor: AuthUser) { + if (await this.permissions.isSuperAdmin(actor.id)) { + return; + } + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId: actor.id }, + }, + }); + + if (!membership) { + throw new ForbiddenException('You are not a customer of this business'); + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..5647eb1 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,25 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.setGlobalPrefix('api/v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalInterceptors(new BigIntSerializerInterceptor()); + app.enableCors(); + + const port = process.env.PORT ?? 3000; + await app.listen(port); + console.log(`API running on http://localhost:${port}/api/v1`); +} + +bootstrap(); diff --git a/src/media/dto/list-media.dto.ts b/src/media/dto/list-media.dto.ts new file mode 100644 index 0000000..03c3fb3 --- /dev/null +++ b/src/media/dto/list-media.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { MediaType } from '@prisma/client'; + +export class ListMediaDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @IsOptional() + @IsString() + mediaType?: MediaType; +} diff --git a/src/media/dto/update-media.dto.ts b/src/media/dto/update-media.dto.ts new file mode 100644 index 0000000..f1ea1c8 --- /dev/null +++ b/src/media/dto/update-media.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateMediaDto { + @IsOptional() + @IsString() + @MaxLength(255) + altText?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + caption?: string; +} diff --git a/src/media/media.controller.ts b/src/media/media.controller.ts new file mode 100644 index 0000000..dcaa0fc --- /dev/null +++ b/src/media/media.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UploadedFiles, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ListMediaDto } from './dto/list-media.dto'; +import { UpdateMediaDto } from './dto/update-media.dto'; +import { MediaService } from './media.service'; + +@Controller('businesses/:businessId/media') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class MediaController { + constructor(private readonly mediaService: MediaService) {} + + @Get() + @RequireBusinessPermission('media.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListMediaDto, + @CurrentUser() user: AuthUser, + ) { + return this.mediaService.list(businessId, query, user); + } + + @Post() + @RequireBusinessPermission('media.create') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + upload( + @Param('businessId') businessId: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUser, + ) { + return this.mediaService.uploadMany(businessId, files ?? [], user); + } + + @Patch(':mediaId') + @RequireBusinessPermission('media.update') + update( + @Param('businessId') businessId: string, + @Param('mediaId') mediaId: string, + @Body() dto: UpdateMediaDto, + @CurrentUser() user: AuthUser, + ) { + return this.mediaService.update(businessId, mediaId, dto, user); + } + + @Delete(':mediaId') + @RequireBusinessPermission('media.delete') + remove( + @Param('businessId') businessId: string, + @Param('mediaId') mediaId: string, + @CurrentUser() user: AuthUser, + ) { + return this.mediaService.remove(businessId, mediaId, user); + } +} diff --git a/src/media/media.module.ts b/src/media/media.module.ts new file mode 100644 index 0000000..4b4336f --- /dev/null +++ b/src/media/media.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { MediaController } from './media.controller'; +import { MediaService } from './media.service'; + +@Module({ + imports: [AuthModule], + controllers: [MediaController], + providers: [MediaService], +}) +export class MediaModule {} diff --git a/src/media/media.service.ts b/src/media/media.service.ts new file mode 100644 index 0000000..f646e1c --- /dev/null +++ b/src/media/media.service.ts @@ -0,0 +1,374 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MediaType } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import * as path from 'path'; +import sharp from 'sharp'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { StorageService } from '../storage/storage.service'; +import { ListMediaDto } from './dto/list-media.dto'; +import { UpdateMediaDto } from './dto/update-media.dto'; + +const IMAGE_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]); + +const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']); + +@Injectable() +export class MediaService { + private readonly maxFileSizeBytes: number; + + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + private readonly permissions: PermissionsService, + private readonly config: ConfigService, + ) { + const maxMb = Number(this.config.get('MEDIA_MAX_FILE_SIZE_MB', '10')); + this.maxFileSizeBytes = maxMb * 1024 * 1024; + } + + async list(businessIdRaw: string, query: ListMediaDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertCanRead(businessId, actor.id); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 24; + const skip = (page - 1) * pageSize; + + const where = { + businessId, + ...(query.mediaType ? { mediaType: query.mediaType } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.media.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.media.count({ where }), + ]); + + return { + items: items.map((item) => this.serialize(item)), + total, + page, + pageSize, + }; + } + + async uploadMany( + businessIdRaw: string, + files: Express.Multer.File[], + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertCanCreate(businessId, actor.id); + + if (!files.length) { + throw new BadRequestException('At least one file is required'); + } + + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + + if (!business?.isActive) { + throw new NotFoundException('Business not found'); + } + + const items = []; + for (const file of files) { + items.push(await this.uploadOne(businessId, business.slug, file, actor.id)); + } + + return { items }; + } + + async update( + businessIdRaw: string, + mediaIdRaw: string, + dto: UpdateMediaDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const mediaId = BigInt(mediaIdRaw); + await this.assertCanUpdate(businessId, actor.id); + + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + + if (!media) { + throw new NotFoundException('Media not found'); + } + + const updated = await this.prisma.media.update({ + where: { id: mediaId }, + data: { + altText: dto.altText?.trim() ?? undefined, + caption: dto.caption?.trim() ?? undefined, + }, + }); + + return this.serialize(updated); + } + + async remove(businessIdRaw: string, mediaIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const mediaId = BigInt(mediaIdRaw); + await this.assertCanDelete(businessId, actor.id); + + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + + if (!media) { + throw new NotFoundException('Media not found'); + } + + await this.prisma.media.delete({ where: { id: mediaId } }); + + try { + await this.storage.delete(media.storagePath, media.storageDisk); + } catch { + // DB row removed; orphaned object can be cleaned later + } + + return { message: 'Media deleted' }; + } + + private async uploadOne( + businessId: bigint, + businessSlug: string, + file: Express.Multer.File, + uploadedBy: bigint, + ) { + if (!file.buffer?.length) { + throw new BadRequestException('Uploaded file is empty'); + } + + if (file.size > this.maxFileSizeBytes) { + throw new BadRequestException( + `File ${file.originalname} exceeds the maximum allowed size`, + ); + } + + if (VIDEO_MIME_TYPES.has(file.mimetype)) { + throw new BadRequestException('Video upload is not enabled for product images yet'); + } + + let width: number | null = null; + let height: number | null = null; + let contentType = file.mimetype; + + try { + const metadata = await sharp(file.buffer).metadata(); + width = metadata.width ?? null; + height = metadata.height ?? null; + + if (!width || !height) { + throw new BadRequestException( + `Could not read image dimensions for ${file.originalname}`, + ); + } + + contentType = + this.contentTypeFromFormat(metadata.format) ?? + (IMAGE_MIME_TYPES.has(file.mimetype) ? file.mimetype : 'image/jpeg'); + } catch (error) { + if (error instanceof BadRequestException) { + throw error; + } + + throw new BadRequestException( + `Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`, + ); + } + + const ext = this.extensionFromContentType(contentType); + const fileName = `${randomUUID()}${ext}`; + const storageKey = `businesses/${businessSlug}/${businessId}/media/${fileName}`; + + const stored = await this.storage.upload({ + key: storageKey, + body: file.buffer, + contentType, + }); + + const created = await this.prisma.media.create({ + data: { + businessId, + uploadedBy, + mediaType: MediaType.image, + storageDisk: stored.storageDisk, + storagePath: stored.storagePath, + publicUrl: stored.publicUrl, + fileName, + originalFileName: file.originalname, + mimeType: contentType, + fileSizeBytes: BigInt(file.size), + width, + height, + }, + }); + + return this.serialize(created); + } + + private contentTypeFromFormat(format?: string) { + switch (format) { + case 'jpeg': + case 'jpg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'webp': + return 'image/webp'; + case 'gif': + return 'image/gif'; + default: + return undefined; + } + } + + private extensionFromContentType(contentType: string) { + switch (contentType) { + case 'image/jpeg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + default: + return '.jpg'; + } + } + + private resolveExtension(originalName: string, mimeType: string) { + const ext = path.extname(originalName).toLowerCase(); + if (ext) { + return ext; + } + + switch (mimeType) { + case 'image/jpeg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/webp': + return '.webp'; + case 'image/gif': + return '.gif'; + case 'video/mp4': + return '.mp4'; + case 'video/webm': + return '.webm'; + default: + return ''; + } + } + + private serialize(media: { + id: bigint; + businessId: bigint; + uploadedBy: bigint | null; + mediaType: MediaType; + storageDisk: string; + storagePath: string; + publicUrl: string; + fileName: string; + originalFileName: string; + mimeType: string; + fileSizeBytes: bigint; + width: number | null; + height: number | null; + durationSeconds: { toNumber?: () => number } | null; + altText: string | null; + caption: string | null; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: media.id.toString(), + businessId: media.businessId.toString(), + uploadedBy: media.uploadedBy?.toString() ?? null, + mediaType: media.mediaType, + storageDisk: media.storageDisk, + storagePath: media.storagePath, + publicUrl: media.publicUrl, + fileName: media.fileName, + originalFileName: media.originalFileName, + mimeType: media.mimeType, + fileSizeBytes: media.fileSizeBytes.toString(), + width: media.width, + height: media.height, + durationSeconds: media.durationSeconds + ? Number(media.durationSeconds) + : null, + altText: media.altText, + caption: media.caption, + createdAt: media.createdAt, + updatedAt: media.updatedAt, + }; + } + + private async assertCanRead(businessId: bigint, userId: bigint) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + 'media.read', + ); + if (!allowed) { + throw new ForbiddenException('You cannot view media for this business'); + } + } + + private async assertCanCreate(businessId: bigint, userId: bigint) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + 'media.create', + ); + if (!allowed) { + throw new ForbiddenException('You cannot upload media for this business'); + } + } + + private async assertCanUpdate(businessId: bigint, userId: bigint) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + 'media.update', + ); + if (!allowed) { + throw new ForbiddenException('You cannot update media for this business'); + } + } + + private async assertCanDelete(businessId: bigint, userId: bigint) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + 'media.delete', + ); + if (!allowed) { + throw new ForbiddenException('You cannot delete media for this business'); + } + } +} diff --git a/src/orders/dto/order.dto.ts b/src/orders/dto/order.dto.ts new file mode 100644 index 0000000..1a39775 --- /dev/null +++ b/src/orders/dto/order.dto.ts @@ -0,0 +1,169 @@ +import { + ArrayMinSize, + IsDateString, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { OrderStatus, TransactionType } from '@prisma/client'; +import { ShippingAddressDto } from '../../cart/dto/cart.dto'; + +export class AdminOrderPaymentDto { + @IsEnum(TransactionType) + type!: TransactionType; + + @IsNumber() + @Min(0.01) + @Type(() => Number) + amount!: number; + + @ValidateIf((dto) => dto.type === TransactionType.pos) + @IsOptional() + @IsString() + posType?: string; + + @ValidateIf((dto) => dto.type === TransactionType.transfer) + @IsString() + @MinLength(1) + transferAccount?: string; + + @ValidateIf((dto) => dto.type === TransactionType.transfer) + @IsString() + @MinLength(1) + transferRefNumber?: string; + + @ValidateIf((dto) => dto.type === TransactionType.e_payment_gate) + @IsOptional() + @IsString() + gatewayType?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class OrderItemInputDto { + @IsString() + @MinLength(1) + storeItemVariantId!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + quantity!: number; +} + +export class CreateAdminOrderDto { + @IsString() + @MinLength(1) + customerUserId!: string; + + @ValidateNested({ each: true }) + @Type(() => OrderItemInputDto) + @ArrayMinSize(1) + items!: OrderItemInputDto[]; + + @IsOptional() + @IsString() + @MinLength(1) + addressId?: string; + + @IsOptional() + @ValidateNested() + @Type(() => ShippingAddressDto) + shippingAddress?: ShippingAddressDto; + + @IsOptional() + @IsEnum(OrderStatus) + status?: OrderStatus; + + @IsOptional() + @IsString() + customerNotes?: string; + + @IsOptional() + @IsString() + adminNotes?: string; + + @IsOptional() + @ValidateNested({ each: true }) + @Type(() => AdminOrderPaymentDto) + @ArrayMinSize(1) + payments?: AdminOrderPaymentDto[]; +} + +export class UpdateOrderDto { + @IsOptional() + @IsEnum(OrderStatus) + status?: OrderStatus; + + @IsOptional() + @IsString() + @MinLength(1) + processStepId?: string; + + @IsOptional() + @IsString() + adminNotes?: string; +} + +export class ListOrdersDto { + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + pageSize?: number; + + @IsOptional() + @IsEnum(OrderStatus) + status?: OrderStatus; + + @IsOptional() + @IsString() + @MinLength(1) + customerUserId?: string; + + @IsOptional() + @IsString() + @MinLength(1) + orderNumber?: string; + + /** Matches customer first name, last name, or cell number */ + @IsOptional() + @IsString() + @MinLength(1) + customerQuery?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + minTotal?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + maxTotal?: number; +} diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts new file mode 100644 index 0000000..15d49a4 --- /dev/null +++ b/src/orders/orders.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateAdminOrderDto, + ListOrdersDto, + UpdateOrderDto, +} from './dto/order.dto'; +import { OrdersService } from './orders.service'; + +@Controller('businesses/:businessId/orders') +@UseGuards(JwtAuthGuard) +export class OrdersController { + constructor(private readonly service: OrdersService) {} + + @Get() + list( + @Param('businessId') businessId: string, + @Query() query: ListOrdersDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':orderId') + getOne( + @Param('businessId') businessId: string, + @Param('orderId') orderId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, orderId, user); + } + + @Post() + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.create') + createAdmin( + @Param('businessId') businessId: string, + @Body() dto: CreateAdminOrderDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.createAdmin(businessId, dto, user); + } + + @Patch(':orderId') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + update( + @Param('businessId') businessId: string, + @Param('orderId') orderId: string, + @Body() dto: UpdateOrderDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, orderId, dto, user); + } + + @Delete(':orderId') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + remove( + @Param('businessId') businessId: string, + @Param('orderId') orderId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, orderId, user); + } +} diff --git a/src/orders/orders.module.ts b/src/orders/orders.module.ts new file mode 100644 index 0000000..08cfda1 --- /dev/null +++ b/src/orders/orders.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; +import { OrdersController } from './orders.controller'; +import { OrdersService } from './orders.service'; + +@Module({ + imports: [AuthModule, BusinessSettingsModule], + controllers: [OrdersController], + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts new file mode 100644 index 0000000..abc02c3 --- /dev/null +++ b/src/orders/orders.service.ts @@ -0,0 +1,922 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + ContentStatus, + MediaEntityType, + OrderSource, + OrderStatus, + Prisma, + Transaction, + TransactionType, +} from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { BusinessSettingsService } from '../business-settings/business-settings.service'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateAdminOrderDto, + ListOrdersDto, + UpdateOrderDto, +} from './dto/order.dto'; +import { ShippingAddressDto } from '../cart/dto/cart.dto'; + +type OrderWithItems = Prisma.OrderGetPayload<{ + include: typeof ORDER_RELATIONS_INCLUDE; +}>; + +const ORDER_RELATIONS_INCLUDE = { + items: { + include: { + product: { + include: { + featuredMedia: true, + }, + }, + }, + }, + transactions: true, + customer: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + email: true, + }, + }, +} as const; + +type TransactionPaymentInput = { + type: TransactionType; + amount: number; + posType?: string; + gatewayType?: string; + transferAccount?: string; + transferRefNumber?: string; + notes?: string | null; +}; + +type CartItemForOrder = { + quantity: number; + storeItemVariant: { + id: bigint; + sku: string | null; + price: Prisma.Decimal | null; + compareAtPrice: Prisma.Decimal | null; + stockQuantity: number | null; + storeItem: { + product: { + id: bigint; + title: string; + status: ContentStatus; + }; + }; + selections: { + variation: { id: bigint; name: string }; + option: { id: bigint; label: string }; + }[]; + }; +}; + +type OrderItemInput = { + storeItemVariantId: bigint; + quantity: number; +}; + +@Injectable() +export class OrdersService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly businessSettings: BusinessSettingsService, + ) {} + + async list(businessIdRaw: string, query: ListOrdersDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const scope = await this.resolveReadScope(businessId, actor); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.OrderWhereInput = { + businessId, + ...(scope === 'customer' ? { userId: actor.id } : {}), + ...(query.status ? { status: query.status } : {}), + ...(scope === 'admin' && query.customerUserId + ? { userId: BigInt(query.customerUserId) } + : {}), + ...(query.orderNumber + ? { + orderNumber: { + contains: query.orderNumber.trim(), + mode: 'insensitive', + }, + } + : {}), + ...(query.dateFrom || query.dateTo + ? { + createdAt: { + ...(query.dateFrom ? { gte: new Date(query.dateFrom) } : {}), + ...(query.dateTo + ? { + lte: (() => { + const end = new Date(query.dateTo); + end.setHours(23, 59, 59, 999); + return end; + })(), + } + : {}), + }, + } + : {}), + ...(query.minTotal !== undefined || query.maxTotal !== undefined + ? { + total: { + ...(query.minTotal !== undefined ? { gte: query.minTotal } : {}), + ...(query.maxTotal !== undefined ? { lte: query.maxTotal } : {}), + }, + } + : {}), + ...(scope === 'admin' && query.customerQuery?.trim() + ? { + customer: this.buildCustomerSearchFilter(query.customerQuery.trim()), + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.order.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: ORDER_RELATIONS_INCLUDE, + }), + this.prisma.order.count({ where }), + ]); + + return { + items: await this.serializeManyWithGallery(items), + total, + page, + pageSize, + }; + } + + async getOne(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const orderId = BigInt(orderIdRaw); + const scope = await this.resolveReadScope(businessId, actor); + + const order = await this.prisma.order.findFirst({ + where: { + id: orderId, + businessId, + ...(scope === 'customer' ? { userId: actor.id } : {}), + }, + include: ORDER_RELATIONS_INCLUDE, + }); + + if (!order) { + throw new NotFoundException('Order not found'); + } + + return { order: await this.serializeWithGallery(order) }; + } + + async createAdmin( + businessIdRaw: string, + dto: CreateAdminOrderDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.create'); + + const customerUserId = BigInt(dto.customerUserId); + await this.assertBusinessCustomer(businessId, customerUserId); + + const shippingAddress = await this.resolveShippingAddress( + customerUserId, + dto.addressId, + dto.shippingAddress, + false, + ); + + const preparedItems = await this.prepareItems( + businessId, + dto.items.map((item) => ({ + storeItemVariantId: BigInt(item.storeItemVariantId), + quantity: item.quantity, + })), + ); + + const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0); + + if (dto.payments?.length) { + const paymentTotal = dto.payments.reduce( + (sum, payment) => sum + Number(payment.amount), + 0, + ); + if (Math.abs(paymentTotal - orderTotal) > 0.009) { + throw new BadRequestException( + 'Payment amounts must equal order total', + ); + } + } + + const order = await this.createOrder({ + businessId, + userId: customerUserId, + createdBy: actor.id, + source: 'admin', + items: preparedItems, + shippingAddress, + addressId: dto.addressId ? BigInt(dto.addressId) : null, + customerNotes: dto.customerNotes?.trim() || null, + adminNotes: dto.adminNotes?.trim() || null, + status: dto.status ?? 'pending', + payments: dto.payments?.map((payment) => ({ + type: payment.type, + amount: Number(payment.amount), + posType: payment.posType, + gatewayType: payment.gatewayType, + transferAccount: payment.transferAccount, + transferRefNumber: payment.transferRefNumber, + notes: payment.notes?.trim() || null, + })), + }); + + return { + message: 'Order created successfully', + order, + }; + } + + async update( + businessIdRaw: string, + orderIdRaw: string, + dto: UpdateOrderDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const orderId = BigInt(orderIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.order.findFirst({ + where: { id: orderId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Order not found'); + } + + if (dto.processStepId !== undefined) { + await this.assertValidProcessStepId(businessId, dto.processStepId); + } + + const updated = await this.prisma.order.update({ + where: { id: orderId }, + data: { + ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(dto.processStepId !== undefined + ? { processStepId: dto.processStepId } + : {}), + ...(dto.adminNotes !== undefined + ? { adminNotes: dto.adminNotes.trim() || null } + : {}), + }, + include: ORDER_RELATIONS_INCLUDE, + }); + + return { + message: 'Order updated successfully', + order: await this.serializeWithGallery(updated), + }; + } + + async remove(businessIdRaw: string, orderIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const orderId = BigInt(orderIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.order.findFirst({ + where: { id: orderId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Order not found'); + } + + await this.prisma.order.delete({ + where: { id: orderId }, + }); + + return { + message: 'Order removed successfully', + }; + } + + async createFromCart(input: { + businessId: bigint; + userId: bigint; + createdBy: bigint; + source: OrderSource; + cartItems: CartItemForOrder[]; + shippingAddress: Record; + addressId: bigint | null; + customerNotes: string | null; + adminNotes: string | null; + status: OrderStatus; + payment: Omit; + }) { + const preparedItems = await this.prepareItems( + input.businessId, + input.cartItems.map((item) => ({ + storeItemVariantId: item.storeItemVariant.id, + quantity: item.quantity, + })), + ); + + const orderTotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0); + + return this.createOrder({ + businessId: input.businessId, + userId: input.userId, + createdBy: input.createdBy, + source: input.source, + items: preparedItems, + shippingAddress: input.shippingAddress, + addressId: input.addressId, + customerNotes: input.customerNotes, + adminNotes: input.adminNotes, + status: input.status, + payments: [ + { + ...input.payment, + amount: orderTotal, + }, + ], + }); + } + + private async createOrder(input: { + businessId: bigint; + userId: bigint; + createdBy: bigint; + source: OrderSource; + items: PreparedOrderItem[]; + shippingAddress: Record; + addressId: bigint | null; + customerNotes: string | null; + adminNotes: string | null; + status: OrderStatus; + payments?: TransactionPaymentInput[]; + }) { + const subtotal = input.items.reduce((sum, item) => sum + item.lineTotal, 0); + const shippingTotal = 0; + const discountTotal = 0; + const total = subtotal + shippingTotal - discountTotal; + const processStepId = await this.defaultProcessStepId(input.businessId); + + const order = await this.prisma.$transaction(async (tx) => { + const orderNumber = await this.generateOrderNumber(tx, input.businessId); + + const created = await tx.order.create({ + data: { + businessId: input.businessId, + userId: input.userId, + orderNumber, + status: input.status, + processStepId, + source: input.source, + subtotal, + shippingTotal, + discountTotal, + total, + shippingAddress: input.shippingAddress as Prisma.InputJsonValue, + addressId: input.addressId, + customerNotes: input.customerNotes, + adminNotes: input.adminNotes, + createdBy: input.createdBy, + items: { + create: input.items.map((item) => ({ + storeItemVariantId: item.storeItemVariantId, + productId: item.productId, + productTitle: item.productTitle, + variantSku: item.variantSku, + unitPrice: item.unitPrice, + compareAtPrice: item.compareAtPrice, + quantity: item.quantity, + lineTotal: item.lineTotal, + selectionsSnapshot: item.selectionsSnapshot, + })), + }, + }, + include: ORDER_RELATIONS_INCLUDE, + }); + + if (input.payments?.length) { + for (const payment of input.payments) { + const normalized = this.normalizePaymentFields(payment); + await tx.transaction.create({ + data: { + businessId: input.businessId, + orderId: created.id, + userId: input.userId, + type: normalized.type, + amount: normalized.amount, + status: input.source === 'admin' ? 'completed' : 'pending', + posType: normalized.posType ?? null, + gatewayType: normalized.gatewayType ?? null, + transferAccount: normalized.transferAccount ?? null, + transferRefNumber: normalized.transferRefNumber ?? null, + notes: normalized.notes ?? null, + createdBy: input.createdBy, + }, + }); + } + } + + for (const item of input.items) { + if (item.stockQuantity !== null) { + const updated = await tx.storeItemVariant.updateMany({ + where: { + id: item.storeItemVariantId, + businessId: input.businessId, + stockQuantity: { gte: item.quantity }, + }, + data: { + stockQuantity: { decrement: item.quantity }, + }, + }); + + if (updated.count === 0) { + throw new BadRequestException( + `Insufficient stock for variant ${item.storeItemVariantId.toString()}`, + ); + } + } + } + + return created as OrderWithItems; + }); + + const orderWithTransactions = input.payments?.length + ? await this.prisma.order.findUniqueOrThrow({ + where: { id: order.id }, + include: ORDER_RELATIONS_INCLUDE, + }) + : await this.prisma.order.findUniqueOrThrow({ + where: { id: order.id }, + include: ORDER_RELATIONS_INCLUDE, + }); + + return this.serializeWithGallery(orderWithTransactions); + } + + private async prepareItems(businessId: bigint, items: OrderItemInput[]) { + if (!items.length) { + throw new BadRequestException('At least one order item is required'); + } + + const prepared: PreparedOrderItem[] = []; + + for (const item of items) { + const variant = await this.prisma.storeItemVariant.findFirst({ + where: { + id: item.storeItemVariantId, + businessId, + isActive: true, + }, + include: { + storeItem: { + include: { + product: true, + }, + }, + selections: { + include: { + variation: true, + option: true, + }, + }, + }, + }); + + if (!variant) { + throw new NotFoundException( + `Store item variant ${item.storeItemVariantId.toString()} not found or unavailable`, + ); + } + + const product = variant.storeItem.product; + + if (product.status !== ContentStatus.published) { + throw new BadRequestException( + `Product "${product.title}" is not available for purchase`, + ); + } + + if (variant.price === null) { + throw new BadRequestException( + `Store item variant ${item.storeItemVariantId.toString()} has no price configured`, + ); + } + + if (variant.stockQuantity !== null && item.quantity > variant.stockQuantity) { + throw new BadRequestException( + `Insufficient stock for variant ${item.storeItemVariantId.toString()}`, + ); + } + + const price = Number(variant.price); + const compareAtPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + const unitPrice = + compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price; + const selectionsSnapshot = variant.selections.map((selection) => ({ + variationId: selection.variation.id.toString(), + variationName: selection.variation.name, + optionId: selection.option.id.toString(), + value: selection.option.label, + })); + + prepared.push({ + storeItemVariantId: variant.id, + productId: product.id, + productTitle: product.title, + variantSku: variant.sku, + unitPrice, + compareAtPrice: price, + quantity: item.quantity, + lineTotal: unitPrice * item.quantity, + stockQuantity: variant.stockQuantity, + selectionsSnapshot, + }); + } + + return prepared; + } + + private async generateOrderNumber( + tx: Prisma.TransactionClient, + businessId: bigint, + ) { + const today = new Date(); + const datePart = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, '0'), + String(today.getDate()).padStart(2, '0'), + ].join(''); + + const prefix = `ORD-${datePart}-`; + const latest = await tx.order.findFirst({ + where: { + businessId, + orderNumber: { startsWith: prefix }, + }, + orderBy: { orderNumber: 'desc' }, + select: { orderNumber: true }, + }); + + const nextSeq = latest + ? Number(latest.orderNumber.slice(prefix.length)) + 1 + : 1; + + return `${prefix}${String(nextSeq).padStart(5, '0')}`; + } + + private async resolveShippingAddress( + userId: bigint, + addressIdRaw: string | undefined, + inline: ShippingAddressDto | undefined, + required: boolean, + ) { + if (addressIdRaw) { + const address = await this.prisma.address.findFirst({ + where: { id: BigInt(addressIdRaw), userId }, + }); + + if (!address) { + throw new NotFoundException('Shipping address not found'); + } + + return { + label: address.label, + province: address.province, + city: address.city, + address: address.address, + postalCode: address.postalCode, + landline: address.landline, + }; + } + + if (inline) { + return { + province: inline.province.trim(), + city: inline.city.trim(), + address: inline.address.trim(), + postalCode: inline.postalCode?.trim() || null, + landline: inline.landline?.trim() || null, + }; + } + + if (required) { + throw new BadRequestException( + 'Provide addressId or shippingAddress for the order', + ); + } + + return {}; + } + + private normalizePaymentFields( + payment: TransactionPaymentInput, + ): TransactionPaymentInput { + switch (payment.type) { + case TransactionType.pos: + return { + ...payment, + posType: payment.posType?.trim() || 'operator', + gatewayType: undefined, + transferAccount: undefined, + transferRefNumber: undefined, + }; + case TransactionType.cash: + return { + ...payment, + posType: undefined, + gatewayType: undefined, + transferAccount: undefined, + transferRefNumber: undefined, + }; + case TransactionType.transfer: + return { + ...payment, + posType: undefined, + gatewayType: undefined, + transferAccount: payment.transferAccount?.trim(), + transferRefNumber: payment.transferRefNumber?.trim(), + }; + case TransactionType.e_payment_gate: + return { + ...payment, + posType: undefined, + transferAccount: undefined, + transferRefNumber: undefined, + gatewayType: payment.gatewayType?.trim() || 'default', + }; + default: + return payment; + } + } + + private buildCustomerSearchFilter(query: string): Prisma.UserWhereInput { + const digits = query.replace(/\D/g, ''); + const or: Prisma.UserWhereInput[] = [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + ]; + + if (digits.length >= 3) { + or.push({ cellNumber: { contains: digits } }); + if (digits.startsWith('0')) { + or.push({ cellNumber: { contains: `+98${digits.slice(1)}` } }); + } else if (digits.startsWith('98')) { + or.push({ cellNumber: { contains: `+${digits}` } }); + } else if (digits.length === 10 && digits.startsWith('9')) { + or.push({ cellNumber: { contains: `+98${digits}` } }); + } + } + + return { OR: or }; + } + + private async defaultProcessStepId(businessId: bigint) { + const steps = await this.businessSettings.getOrderProcessSteps(businessId); + return steps[0]?.id ?? 'processing'; + } + + private async assertValidProcessStepId(businessId: bigint, processStepId: string) { + const steps = await this.businessSettings.getOrderProcessSteps(businessId); + const valid = steps.some((step) => step.id === processStepId); + if (!valid) { + throw new BadRequestException('Invalid order process step'); + } + } + + private async serializeWithGallery(order: OrderWithItems) { + const [serialized] = await this.serializeManyWithGallery([order]); + return serialized; + } + + private async serializeManyWithGallery(orders: OrderWithItems[]) { + if (orders.length === 0) { + return []; + } + + const businessId = orders[0].businessId; + const productIds = this.collectProductIdsNeedingGallery(orders); + const [galleryByProductId, processSteps] = await Promise.all([ + this.loadProductGalleryUrls(businessId, productIds), + this.businessSettings.getOrderProcessSteps(businessId), + ]); + + return orders.map((order) => + this.serialize(order, galleryByProductId, processSteps), + ); + } + + private collectProductIdsNeedingGallery(orders: OrderWithItems[]) { + const ids = new Set(); + + for (const order of orders) { + for (const item of order.items) { + if (!item.product.featuredMedia?.publicUrl) { + ids.add(item.productId); + } + } + } + + return [...ids]; + } + + private async loadProductGalleryUrls(businessId: bigint, productIds: bigint[]) { + if (productIds.length === 0) { + return new Map(); + } + + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: { in: productIds }, + isFeatured: false, + }, + orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }], + include: { media: true }, + }); + + const map = new Map(); + for (const attachment of attachments) { + const key = attachment.entityId.toString(); + if (!map.has(key)) { + map.set(key, attachment.media.publicUrl); + } + } + + return map; + } + + private resolveProductImage( + product: OrderWithItems['items'][number]['product'], + galleryByProductId: Map, + ) { + const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; + if (thumbnailUrl) { + return thumbnailUrl; + } + + return galleryByProductId.get(product.id.toString()) ?? null; + } + + private serialize( + order: OrderWithItems, + galleryByProductId: Map = new Map(), + processSteps: { id: string; label: string; color: string }[] = [], + ) { + const processStep = + processSteps.find((step) => step.id === order.processStepId) ?? null; + + return { + id: order.id.toString(), + businessId: order.businessId.toString(), + orderNumber: order.orderNumber, + status: order.status, + processStepId: order.processStepId, + processStepLabel: processStep?.label ?? null, + processStepColor: processStep?.color ?? null, + source: order.source, + subtotal: Number(order.subtotal), + shippingTotal: Number(order.shippingTotal), + discountTotal: Number(order.discountTotal), + total: Number(order.total), + shippingAddress: order.shippingAddress, + addressId: order.addressId?.toString() ?? null, + customerNotes: order.customerNotes, + adminNotes: order.adminNotes, + createdBy: order.createdBy?.toString() ?? null, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + customer: { + id: order.customer.id.toString(), + firstName: order.customer.firstName, + lastName: order.customer.lastName, + cellNumber: order.customer.cellNumber, + email: order.customer.email, + }, + items: order.items.map((item) => ({ + id: item.id.toString(), + storeItemVariantId: item.storeItemVariantId?.toString() ?? null, + productId: item.productId.toString(), + productTitle: item.productTitle, + productImage: this.resolveProductImage(item.product, galleryByProductId), + variantSku: item.variantSku, + unitPrice: Number(item.unitPrice), + compareAtPrice: + item.compareAtPrice === null ? null : Number(item.compareAtPrice), + quantity: item.quantity, + lineTotal: Number(item.lineTotal), + selections: item.selectionsSnapshot, + })), + transactions: (order.transactions ?? []).map((transaction) => + this.serializeTransaction(transaction), + ), + }; + } + + private serializeTransaction(transaction: Transaction) { + return { + id: transaction.id.toString(), + type: transaction.type, + status: transaction.status, + amount: Number(transaction.amount), + posType: transaction.posType, + gatewayType: transaction.gatewayType, + transferAccount: transaction.transferAccount, + transferRefNumber: transaction.transferRefNumber, + notes: transaction.notes, + createdBy: transaction.createdBy?.toString() ?? null, + createdAt: transaction.createdAt, + updatedAt: transaction.updatedAt, + }; + } + + private async resolveReadScope(businessId: bigint, actor: AuthUser) { + if ( + await this.permissions.hasBusinessPermission( + actor.id, + businessId, + 'orders.read', + ) + ) { + return 'admin' as const; + } + + await this.assertBusinessCustomer(businessId, actor.id); + return 'customer' as const; + } + + private async assertBusinessCustomer(businessId: bigint, userId: bigint) { + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + }); + + if (!membership) { + throw new ForbiddenException('User is not a customer of this business'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} + +type PreparedOrderItem = { + storeItemVariantId: bigint; + productId: bigint; + productTitle: string; + variantSku: string | null; + unitPrice: number; + compareAtPrice: number; + quantity: number; + lineTotal: number; + stockQuantity: number | null; + selectionsSnapshot: { + variationId: string; + variationName: string; + optionId: string; + value: string; + }[]; +}; diff --git a/src/portfolios/dto/portfolio.dto.ts b/src/portfolios/dto/portfolio.dto.ts new file mode 100644 index 0000000..1dde54e --- /dev/null +++ b/src/portfolios/dto/portfolio.dto.ts @@ -0,0 +1,168 @@ +import { ContentStatus } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Min, + MinLength, +} from 'class-validator'; + +export class ListPortfoliosDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class ListPublicPortfoliosDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + title?: string; +} + +export class CreatePortfolioDto { + @IsString() + @MinLength(2) + title!: string; + + @IsOptional() + @IsString() + abstract?: string; + + @IsOptional() + @IsString() + mainTextHtml?: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + /** Title image (recommended 3:2 aspect ratio). */ + @IsOptional() + @IsString() + featuredMediaId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} + +export class UpdatePortfolioDto { + @IsOptional() + @IsString() + @MinLength(2) + title?: string; + + @IsOptional() + @IsString() + abstract?: string | null; + + @IsOptional() + @IsString() + mainTextHtml?: string | null; + + @IsOptional() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} + +export class CreatePortfolioCommentDto { + @IsString() + @MinLength(2) + authorName!: string; + + @IsOptional() + @IsString() + authorEmail?: string; + + @IsString() + @MinLength(1) + text!: string; +} diff --git a/src/portfolios/portfolios.controller.ts b/src/portfolios/portfolios.controller.ts new file mode 100644 index 0000000..6eb554c --- /dev/null +++ b/src/portfolios/portfolios.controller.ts @@ -0,0 +1,123 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreatePortfolioCommentDto, + CreatePortfolioDto, + ListPortfoliosDto, + ListPublicPortfoliosDto, + UpdatePortfolioDto, +} from './dto/portfolio.dto'; +import { PortfoliosService } from './portfolios.service'; + +@Controller('businesses/:businessId/portfolios') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class PortfoliosController { + constructor(private readonly service: PortfoliosService) {} + + @Get() + @RequireBusinessPermission('portfolios.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListPortfoliosDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':portfolioId') + @RequireBusinessPermission('portfolios.read') + getOne( + @Param('businessId') businessId: string, + @Param('portfolioId') portfolioId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, portfolioId, user); + } + + @Post() + @RequireBusinessPermission('portfolios.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreatePortfolioDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':portfolioId') + @RequireBusinessPermission('portfolios.update') + update( + @Param('businessId') businessId: string, + @Param('portfolioId') portfolioId: string, + @Body() dto: UpdatePortfolioDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, portfolioId, dto, user); + } + + @Delete(':portfolioId') + @RequireBusinessPermission('portfolios.delete') + remove( + @Param('businessId') businessId: string, + @Param('portfolioId') portfolioId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, portfolioId, user); + } + + @Get(':portfolioId/comments') + @RequireBusinessPermission('comments.read') + listComments( + @Param('businessId') businessId: string, + @Param('portfolioId') portfolioId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.listCommentsAdmin(businessId, portfolioId, user); + } +} + +@Controller('tenants/:host/portfolios') +export class PublicPortfoliosController { + constructor(private readonly service: PortfoliosService) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) { + return this.service.listPublic(host, query); + } + + @Get(':portfolioId/comments') + listComments( + @Param('host') host: string, + @Param('portfolioId') portfolioId: string, + ) { + return this.service.listCommentsPublic(host, portfolioId); + } + + @Post(':portfolioId/comments') + createComment( + @Param('host') host: string, + @Param('portfolioId') portfolioId: string, + @Body() dto: CreatePortfolioCommentDto, + ) { + return this.service.createCommentPublic(host, portfolioId, dto); + } + + @Get(':slug') + getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + return this.service.getPublicBySlug(host, slug); + } +} diff --git a/src/portfolios/portfolios.module.ts b/src/portfolios/portfolios.module.ts new file mode 100644 index 0000000..2f69b42 --- /dev/null +++ b/src/portfolios/portfolios.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BusinessSettingsModule } from '../business-settings/business-settings.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + PortfoliosController, + PublicPortfoliosController, +} from './portfolios.controller'; +import { PortfoliosService } from './portfolios.service'; + +@Module({ + imports: [AuthModule, BusinessSettingsModule, TenantModule], + controllers: [PortfoliosController, PublicPortfoliosController], + providers: [PortfoliosService], +}) +export class PortfoliosModule {} diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts new file mode 100644 index 0000000..9af13f0 --- /dev/null +++ b/src/portfolios/portfolios.service.ts @@ -0,0 +1,832 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + ContentStatus, + MediaEntityType, + Prisma, +} from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { BusinessSettingsService } from '../business-settings/business-settings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreatePortfolioCommentDto, + CreatePortfolioDto, + ListPortfoliosDto, + ListPublicPortfoliosDto, + UpdatePortfolioDto, +} from './dto/portfolio.dto'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'portfolio' + ); +} + +type PortfolioWithRelations = Prisma.portfoliosGetPayload<{ + include: { media: true }; +}>; + +const portfolioInclude = { + media: true, +} satisfies Prisma.portfoliosInclude; + +@Injectable() +export class PortfoliosService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + private readonly businessSettings: BusinessSettingsService, + ) {} + + async list(businessIdRaw: string, query: ListPortfoliosDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'portfolios.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + + const where = await this.buildWhere(businessId, query); + + const [items, total] = await Promise.all([ + this.prisma.portfolios.findMany({ + where, + orderBy: [ + { sort_order: 'asc' }, + { published_at: 'desc' }, + { created_at: 'desc' }, + ], + skip, + take: pageSize, + include: portfolioInclude, + }), + this.prisma.portfolios.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => + this.serializePortfolio(item, { includeComments: true }), + ), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getOne( + businessIdRaw: string, + portfolioIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const portfolioId = BigInt(portfolioIdRaw); + await this.assertPermission(businessId, actor.id, 'portfolios.read'); + + const portfolio = await this.findPortfolioOrThrow(businessId, portfolioId); + + return { + portfolio: await this.serializePortfolio(portfolio, { + includeComments: true, + }), + }; + } + + async create( + businessIdRaw: string, + dto: CreatePortfolioDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'portfolios.create'); + + const slug = await this.ensureUniqueSlug( + businessId, + dto.slug ?? slugify(dto.title), + ); + + const status = dto.status ?? ContentStatus.draft; + const featuredMediaId = dto.featuredMediaId + ? BigInt(dto.featuredMediaId) + : null; + + if (featuredMediaId) { + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds ?? [], + ); + + if (dto.categoryId) { + await this.assertCategoryBelongsToBusiness( + businessId, + BigInt(dto.categoryId), + ); + } + + const created = await this.prisma.$transaction(async (tx) => { + const portfolio = await tx.portfolios.create({ + data: { + business_id: businessId, + title: dto.title.trim(), + slug, + description: dto.abstract?.trim() || null, + content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue, + status, + featured_media_id: featuredMediaId, + sort_order: dto.sortOrder ?? 0, + published_at: status === ContentStatus.published ? new Date() : null, + metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue, + }, + include: portfolioInclude, + }); + + if (dto.categoryId) { + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId: BigInt(dto.categoryId), + entityType: MediaEntityType.portfolio, + entityId: portfolio.id, + }, + }); + } + + await this.syncGalleryAttachments( + tx, + businessId, + portfolio.id, + galleryMediaIds, + ); + + return portfolio; + }); + + return { + message: 'Portfolio created successfully', + portfolio: await this.serializePortfolio(created), + }; + } + + async update( + businessIdRaw: string, + portfolioIdRaw: string, + dto: UpdatePortfolioDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const portfolioId = BigInt(portfolioIdRaw); + await this.assertPermission(businessId, actor.id, 'portfolios.update'); + + const existing = await this.prisma.portfolios.findFirst({ + where: { id: portfolioId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Portfolio not found'); + } + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug(businessId, dto.slug, portfolioId); + } else if (dto.title && dto.title !== existing.title) { + slug = await this.ensureUniqueSlug( + businessId, + slugify(dto.title), + portfolioId, + ); + } + + let featuredMediaId: bigint | null | undefined = undefined; + if (dto.featuredMediaId !== undefined) { + if (dto.featuredMediaId === null || dto.featuredMediaId === '') { + featuredMediaId = null; + } else { + featuredMediaId = BigInt(dto.featuredMediaId); + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + } + + const existingContent = this.asRecord(existing.content); + const existingMetadata = this.asRecord(existing.metadata); + + const nextContent = { ...existingContent }; + if (dto.mainTextHtml !== undefined) { + nextContent.html = dto.mainTextHtml ?? ''; + } + + const nextMetadata = { ...existingMetadata }; + if (dto.tags !== undefined) { + nextMetadata.tags = dto.tags; + } + + let publishedAt: Date | null | undefined = undefined; + if (dto.status !== undefined) { + if ( + dto.status === ContentStatus.published && + existing.status !== ContentStatus.published + ) { + publishedAt = new Date(); + } + if (dto.status !== ContentStatus.published) { + publishedAt = null; + } + } + + const updated = await this.prisma.$transaction(async (tx) => { + const portfolio = await tx.portfolios.update({ + where: { id: portfolioId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.abstract !== undefined + ? { description: dto.abstract?.trim() || null } + : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(featuredMediaId !== undefined + ? { featured_media_id: featuredMediaId } + : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + ...(publishedAt !== undefined ? { published_at: publishedAt } : {}), + slug, + content: nextContent as Prisma.InputJsonValue, + metadata: nextMetadata as Prisma.InputJsonValue, + }, + include: portfolioInclude, + }); + + if (dto.categoryId !== undefined) { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + }); + + if (dto.categoryId) { + const categoryId = BigInt(dto.categoryId); + await this.assertCategoryBelongsToBusiness(businessId, categoryId); + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + }); + } + } + + if (dto.galleryMediaIds !== undefined) { + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds, + ); + await this.syncGalleryAttachments( + tx, + businessId, + portfolioId, + galleryMediaIds, + ); + } + + return portfolio; + }); + + return { + message: 'Portfolio updated successfully', + portfolio: await this.serializePortfolio(updated), + }; + } + + async remove( + businessIdRaw: string, + portfolioIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const portfolioId = BigInt(portfolioIdRaw); + await this.assertPermission(businessId, actor.id, 'portfolios.delete'); + + const existing = await this.prisma.portfolios.findFirst({ + where: { id: portfolioId, business_id: businessId }, + }); + + if (!existing) { + throw new NotFoundException('Portfolio not found'); + } + + await this.prisma.$transaction([ + this.prisma.comment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + }), + this.prisma.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + }), + this.prisma.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + }), + this.prisma.portfolios.delete({ where: { id: portfolioId } }), + ]); + + return { message: 'Portfolio deleted successfully' }; + } + + async listPublic(host: string, query: ListPublicPortfoliosDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + + const where = await this.buildWhere(businessId, { + ...query, + status: ContentStatus.published, + }); + + const [items, total] = await Promise.all([ + this.prisma.portfolios.findMany({ + where, + orderBy: [ + { sort_order: 'asc' }, + { published_at: 'desc' }, + { created_at: 'desc' }, + ], + skip, + take: pageSize, + include: portfolioInclude, + }), + this.prisma.portfolios.count({ where }), + ]); + + const serialized = await Promise.all( + items.map((item) => + this.serializePortfolio(item, { + includeComments: true, + approvedCommentsOnly: true, + }), + ), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getPublicBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const portfolio = await this.prisma.portfolios.findFirst({ + where: { + business_id: businessId, + slug, + status: ContentStatus.published, + }, + include: portfolioInclude, + }); + + if (!portfolio) { + throw new NotFoundException('Portfolio not found'); + } + + return { + portfolio: await this.serializePortfolio(portfolio, { + includeComments: true, + approvedCommentsOnly: true, + }), + }; + } + + async listCommentsPublic(host: string, portfolioIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const portfolioId = BigInt(portfolioIdRaw); + + await this.assertPublishedPortfolioExists(businessId, portfolioId); + + const items = await this.prisma.comment.findMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + isApproved: true, + }, + orderBy: { createdAt: 'desc' }, + include: { approver: true }, + }); + + return { items: items.map((item) => this.serializeComment(item)) }; + } + + async createCommentPublic( + host: string, + portfolioIdRaw: string, + dto: CreatePortfolioCommentDto, + ) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const portfolioId = BigInt(portfolioIdRaw); + + await this.assertPublishedPortfolioExists(businessId, portfolioId); + + const autoApprove = + await this.businessSettings.isCommentsAutoApprove(businessId); + const approvedAt = autoApprove ? new Date() : null; + + const created = await this.prisma.comment.create({ + data: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + authorName: dto.authorName.trim(), + authorEmail: dto.authorEmail?.trim() || null, + text: dto.text.trim(), + isApproved: autoApprove, + approvedAt, + }, + include: { approver: true }, + }); + + return { + comment: this.serializeComment(created), + message: autoApprove + ? 'Comment submitted and is approved' + : 'Comment submitted and is pending approval', + }; + } + + async listCommentsAdmin( + businessIdRaw: string, + portfolioIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const portfolioId = BigInt(portfolioIdRaw); + await this.assertPermission(businessId, actor.id, 'comments.read'); + + const portfolio = await this.prisma.portfolios.findFirst({ + where: { id: portfolioId, business_id: businessId }, + select: { id: true }, + }); + + if (!portfolio) { + throw new NotFoundException('Portfolio not found'); + } + + const items = await this.prisma.comment.findMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + }, + orderBy: { createdAt: 'desc' }, + include: { approver: true }, + }); + + return { items: items.map((item) => this.serializeComment(item)) }; + } + + private async buildWhere( + businessId: bigint, + query: (ListPortfoliosDto | ListPublicPortfoliosDto) & { + status?: ContentStatus; + }, + ): Promise { + let entityIds: bigint[] | undefined; + + if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.portfolio, + }, + select: { entityId: true }, + }); + + entityIds = assignments.map((item) => item.entityId); + + if (entityIds.length === 0) { + return { id: { in: [] } }; + } + } + + return { + business_id: businessId, + ...(query.status ? { status: query.status } : {}), + ...(entityIds ? { id: { in: entityIds } } : {}), + ...(query.title?.trim() + ? { + title: { contains: query.title.trim(), mode: 'insensitive' }, + } + : {}), + }; + } + + private async findPortfolioOrThrow(businessId: bigint, portfolioId: bigint) { + const portfolio = await this.prisma.portfolios.findFirst({ + where: { id: portfolioId, business_id: businessId }, + include: portfolioInclude, + }); + + if (!portfolio) { + throw new NotFoundException('Portfolio not found'); + } + + return portfolio; + } + + private async assertPublishedPortfolioExists( + businessId: bigint, + portfolioId: bigint, + ) { + const portfolio = await this.prisma.portfolios.findFirst({ + where: { + id: portfolioId, + business_id: businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!portfolio) { + throw new NotFoundException('Portfolio not found'); + } + } + + private async serializePortfolio( + portfolio: PortfolioWithRelations, + options: { + includeComments?: boolean; + approvedCommentsOnly?: boolean; + } = {}, + ) { + const content = this.asRecord(portfolio.content); + const metadata = this.asRecord(portfolio.metadata); + + const [categoryAssignment, galleryAttachments, commentData] = + await Promise.all([ + this.prisma.categoryAssignment.findFirst({ + where: { + businessId: portfolio.business_id, + entityType: MediaEntityType.portfolio, + entityId: portfolio.id, + }, + include: { category: true }, + }), + this.prisma.mediaAttachment.findMany({ + where: { + businessId: portfolio.business_id, + entityType: MediaEntityType.portfolio, + entityId: portfolio.id, + isFeatured: false, + }, + orderBy: { sortOrder: 'asc' }, + include: { media: true }, + }), + options.includeComments + ? this.loadComments( + portfolio.business_id, + portfolio.id, + options.approvedCommentsOnly, + ) + : Promise.resolve({ commentCount: 0, comments: [] }), + ]); + + return { + id: portfolio.id.toString(), + businessId: portfolio.business_id.toString(), + title: portfolio.title, + slug: portfolio.slug, + abstract: portfolio.description ?? '', + mainTextHtml: (content.html as string | undefined) ?? '', + status: portfolio.status, + categoryId: categoryAssignment?.categoryId.toString() ?? null, + categoryName: categoryAssignment?.category.name ?? '', + tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [], + titleImageUrl: + portfolio.media?.publicUrl ?? + galleryAttachments[0]?.media.publicUrl ?? + null, + featuredMediaId: portfolio.featured_media_id?.toString() ?? null, + gallery: galleryAttachments.map((item) => ({ + mediaId: item.mediaId.toString(), + url: item.media.publicUrl, + })), + galleryMediaIds: galleryAttachments.map((item) => + item.mediaId.toString(), + ), + sortOrder: portfolio.sort_order, + commentCount: commentData.commentCount, + comments: commentData.comments, + publishedAt: portfolio.published_at, + createdAt: portfolio.created_at, + updatedAt: portfolio.updated_at, + }; + } + + private async loadComments( + businessId: bigint, + portfolioId: bigint, + approvedOnly?: boolean, + ) { + const where: Prisma.CommentWhereInput = { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + ...(approvedOnly ? { isApproved: true } : {}), + }; + + const [commentCount, comments] = await Promise.all([ + this.prisma.comment.count({ where }), + this.prisma.comment.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: approvedOnly ? 50 : undefined, + include: { approver: true }, + }), + ]); + + return { + commentCount, + comments: comments.map((item) => this.serializeComment(item)), + }; + } + + private serializeComment( + comment: Prisma.CommentGetPayload<{ include: { approver: true } }>, + ) { + return { + id: comment.id.toString(), + businessId: comment.businessId.toString(), + entityType: comment.entityType, + entityId: comment.entityId.toString(), + authorName: comment.authorName, + authorEmail: comment.authorEmail, + text: comment.text, + isApproved: comment.isApproved, + approvedAt: comment.approvedAt, + approvedBy: comment.approvedBy?.toString() ?? null, + approver: comment.approver + ? { + id: comment.approver.id.toString(), + firstName: comment.approver.firstName, + lastName: comment.approver.lastName, + } + : null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + } + + private buildContent(mainTextHtml?: string) { + return { + html: mainTextHtml ?? '', + }; + } + + private buildMetadata(tags?: string[]) { + return { + tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [], + }; + } + + private asRecord(value: Prisma.JsonValue): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async syncGalleryAttachments( + tx: Prisma.TransactionClient, + businessId: bigint, + portfolioId: bigint, + mediaIds: bigint[], + ) { + await tx.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + isFeatured: false, + }, + }); + + for (const [index, mediaId] of mediaIds.entries()) { + await tx.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.portfolio, + entityId: portfolioId, + sortOrder: index, + isFeatured: false, + }, + }); + } + } + + private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { + const ids = rawIds.map((id) => BigInt(id)); + for (const mediaId of ids) { + await this.assertMediaBelongsToBusiness(businessId, mediaId); + } + return ids; + } + + private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + if (!media) { + throw new BadRequestException('Media not found for this business'); + } + } + + private async assertCategoryBelongsToBusiness( + businessId: bigint, + categoryId: bigint, + ) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.portfolio, + isActive: true, + }, + }); + if (!category) { + throw new BadRequestException( + 'Portfolio category not found for this business', + ); + } + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.portfolios.findFirst({ + where: { + business_id: businessId, + slug, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -0,0 +1,16 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/src/products/dto/product-ai.dto.ts b/src/products/dto/product-ai.dto.ts new file mode 100644 index 0000000..c3981d1 --- /dev/null +++ b/src/products/dto/product-ai.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsString, MinLength } from 'class-validator'; + +export enum ProductAiLanguage { + en = 'en', + fa = 'fa', +} + +export class CreateProductByAiDto { + @IsString() + @MinLength(1) + categoryId!: string; + + @IsString() + @MinLength(2) + name!: string; + + @IsEnum(ProductAiLanguage) + language!: ProductAiLanguage; +} diff --git a/src/products/dto/product-technical-info.dto.ts b/src/products/dto/product-technical-info.dto.ts new file mode 100644 index 0000000..6b3c385 --- /dev/null +++ b/src/products/dto/product-technical-info.dto.ts @@ -0,0 +1,24 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class ProductTechnicalFieldValueDto { + @IsString() + @MinLength(1) + fieldKey!: string; + + @IsOptional() + value?: string | string[] | null; +} + +export class ReplaceProductTechnicalInfoDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductTechnicalFieldValueDto) + values!: ProductTechnicalFieldValueDto[]; +} diff --git a/src/products/dto/product-variation-values.dto.ts b/src/products/dto/product-variation-values.dto.ts new file mode 100644 index 0000000..0a9f5bc --- /dev/null +++ b/src/products/dto/product-variation-values.dto.ts @@ -0,0 +1,24 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class ProductVariationSelectionDto { + @IsString() + @MinLength(1) + variationId!: string; + + @IsArray() + @IsString({ each: true }) + optionIds!: string[]; +} + +export class ReplaceProductVariationValuesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductVariationSelectionDto) + selections!: ProductVariationSelectionDto[]; +} diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts new file mode 100644 index 0000000..0698559 --- /dev/null +++ b/src/products/dto/product.dto.ts @@ -0,0 +1,170 @@ +import { ContentStatus } from '@prisma/client'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, + Matches, + Min, + MinLength, +} from 'class-validator'; + +export class ListProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + name?: string; +} + +export class ListPublicProductsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + brandId?: string; + + @IsOptional() + @IsString() + tag?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + inStore?: boolean; +} + +export class CreateProductDto { + @IsString() + @MinLength(2) + title!: string; + + @IsOptional() + @IsString() + nameFa?: string; + + @IsOptional() + @IsString() + summary?: string; + + @IsOptional() + @IsString() + descriptionHtml?: string; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + brandId?: string; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} + +export class UpdateProductDto { + @IsOptional() + @IsString() + @MinLength(2) + title?: string; + + @IsOptional() + @IsString() + nameFa?: string | null; + + @IsOptional() + @IsString() + summary?: string | null; + + @IsOptional() + @IsString() + descriptionHtml?: string | null; + + @IsOptional() + @IsString() + categoryId?: string | null; + + @IsOptional() + @IsString() + brandId?: string | null; + + @IsOptional() + @IsEnum(ContentStatus) + status?: ContentStatus; + + @IsOptional() + @IsString() + featuredMediaId?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + galleryMediaIds?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/) + slug?: string; +} diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts new file mode 100644 index 0000000..a62cdc0 --- /dev/null +++ b/src/products/product-ai.service.ts @@ -0,0 +1,257 @@ +import { + BadRequestException, + Injectable, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ContentStatus, TechnicalFieldType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateProductByAiDto, ProductAiLanguage } from './dto/product-ai.dto'; +import { ProductTechnicalInfoService } from './product-technical-info.service'; +import { ProductsService } from './products.service'; +import { + requestAiJsonCompletion, + resolveAiProvider, +} from '../common/ai-provider.util'; + +type TechnicalFormField = { + key: string; + label: string; + type: TechnicalFieldType; + isRequired: boolean; + options: { value: string; label: string }[]; +}; + +type AiProductDraft = { + title: string; + nameFa: string; + summary: string; + descriptionHtml: string; + tags: string[]; + technicalValues: Record; +}; + +@Injectable() +export class ProductAiService { + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + private readonly products: ProductsService, + private readonly technicalInfo: ProductTechnicalInfoService, + private readonly technicalFormService: CategoryTechnicalFormService, + ) {} + + async createFromAi( + businessIdRaw: string, + dto: CreateProductByAiDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(dto.categoryId); + + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + isActive: true, + }, + }); + + if (!category) { + throw new BadRequestException('Product category not found'); + } + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + + const draft = await this.generateDraft({ + categoryName: category.name, + productName: dto.name.trim(), + language: dto.language, + fields: form?.fields ?? [], + }); + + const created = await this.products.create( + businessIdRaw, + { + title: draft.title, + nameFa: draft.nameFa, + summary: draft.summary, + descriptionHtml: draft.descriptionHtml, + categoryId: dto.categoryId, + tags: draft.tags, + status: ContentStatus.published, + }, + actor, + ); + + if (form && Object.keys(draft.technicalValues).length > 0) { + const values = Object.entries(draft.technicalValues) + .filter(([, value]) => { + if (Array.isArray(value)) return value.length > 0; + return value !== ''; + }) + .map(([fieldKey, value]) => ({ fieldKey, value })); + + if (values.length > 0) { + await this.technicalInfo.replaceForProduct( + businessIdRaw, + created.product.id, + { values }, + actor, + ); + } + } + + return { + message: 'Product created with AI. Add images and variations next.', + product: created.product, + }; + } + + private async generateDraft(input: { + categoryName: string; + productName: string; + language: ProductAiLanguage; + fields: TechnicalFormField[]; + }): Promise { + const provider = resolveAiProvider(this.config); + + const technicalSchema = input.fields.map((field) => ({ + key: field.key, + label: field.label, + type: field.type, + required: field.isRequired, + options: + field.type === 'select' || field.type === 'multi_select' + ? field.options.map((option) => option.value) + : undefined, + })); + + const inputLanguage = + input.language === ProductAiLanguage.fa ? 'Persian (Farsi)' : 'English'; + + const systemPrompt = `You generate e-commerce product drafts for an Iranian marketplace. +Return ONLY valid JSON with this shape: +{ + "title": "English product title", + "nameFa": "Persian product name", + "summary": "short listing summary", + "descriptionHtml": "HTML description using

and

  • only", + "tags": ["tag1", "tag2"], + "technicalValues": { "fieldKey": "value" } +} + +Rules: +- Be factual and realistic for the named product in the given category. +- If technical form fields are provided, fill technicalValues using exact field keys. +- For select fields use one allowed option value exactly. +- For multi_select fields use an array of allowed option values. +- For text/textarea fields use plain strings. +- The user input name is in ${inputLanguage}; keep that spelling in the matching field and translate/generate the other language field. +- descriptionHtml should be 2-4 paragraphs with key specs. +- tags: 3-8 relevant lowercase tags.`; + + const userPrompt = JSON.stringify({ + category: input.categoryName, + inputName: input.productName, + inputLanguage: input.language, + technicalFields: technicalSchema, + }); + + let content: string; + try { + content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt); + } catch (err) { + const message = err instanceof Error ? err.message : 'AI request failed'; + throw new BadRequestException(message); + } + + let parsed: AiProductDraft; + try { + parsed = JSON.parse(content) as AiProductDraft; + } catch { + throw new BadRequestException('AI returned invalid JSON'); + } + + return this.normalizeDraft(parsed, input); + } + + private normalizeDraft( + draft: AiProductDraft, + input: { + productName: string; + language: ProductAiLanguage; + fields: TechnicalFormField[]; + }, + ): AiProductDraft { + const title = + input.language === ProductAiLanguage.en + ? input.productName + : String(draft.title ?? input.productName).trim(); + + const nameFa = + input.language === ProductAiLanguage.fa + ? input.productName + : String(draft.nameFa ?? '').trim(); + + const summary = String(draft.summary ?? '').trim(); + const descriptionHtml = String(draft.descriptionHtml ?? '').trim(); + const tags = Array.isArray(draft.tags) + ? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12) + : []; + + if (!title || title.length < 2) { + throw new BadRequestException('AI draft is missing a valid English title'); + } + + if (!nameFa) { + throw new BadRequestException('AI draft is missing a Persian product name'); + } + + const technicalValues: Record = {}; + const rawTechnical = draft.technicalValues ?? {}; + + for (const field of input.fields) { + const raw = rawTechnical[field.key]; + if (raw === undefined || raw === null || raw === '') continue; + + if (field.type === 'multi_select') { + const values = Array.isArray(raw) ? raw : [String(raw)]; + const allowed = new Set(field.options.map((option) => option.value)); + const filtered = values + .map((value) => String(value).trim()) + .filter((value) => allowed.has(value)); + if (filtered.length > 0) { + technicalValues[field.key] = filtered; + } + continue; + } + + if (field.type === 'select') { + const value = String(raw).trim(); + const allowed = field.options.some((option) => option.value === value); + if (allowed) { + technicalValues[field.key] = value; + } + continue; + } + + technicalValues[field.key] = String(raw).trim(); + } + + return { + title, + nameFa, + summary: summary || `${title} — product details generated by AI.`, + descriptionHtml: + descriptionHtml || + `

    ${summary || `${title} product description.`}

    `, + tags, + technicalValues, + }; + } +} diff --git a/src/products/product-technical-info.service.ts b/src/products/product-technical-info.service.ts new file mode 100644 index 0000000..8e0aad8 --- /dev/null +++ b/src/products/product-technical-info.service.ts @@ -0,0 +1,394 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType, TechnicalFieldType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto'; + +type FormField = { + id: string; + label: string; + key: string; + type: TechnicalFieldType; + isRequired: boolean; + sortOrder: number; + options: { id: string; label: string; value: string; sortOrder: number }[]; +}; + +@Injectable() +export class ProductTechnicalInfoService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly technicalFormService: CategoryTechnicalFormService, + ) {} + + async getForProduct( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + return this.buildTechnicalInfoForProduct(businessId, productId); + } + + async getPublicForProduct(businessIdRaw: string, productIdRaw: string) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + + await this.assertPublishedProductExists(businessId, productId); + + return this.buildTechnicalInfoForProduct(businessId, productId); + } + + private async buildTechnicalInfoForProduct( + businessId: bigint, + productId: bigint, + ) { + const { product, categoryId } = await this.getProductWithCategory( + businessId, + productId, + ); + + if (!categoryId) { + return { + form: null, + values: [], + message: 'Product has no category assigned', + }; + } + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + + if (!form) { + return { form: null, values: [] }; + } + + const values = await this.loadProductValues( + businessId, + product.id, + form.fields, + ); + + return { form, values }; + } + + async replaceForProduct( + businessIdRaw: string, + productIdRaw: string, + dto: ReplaceProductTechnicalInfoDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const { product, categoryId } = await this.getProductWithCategory( + businessId, + productId, + ); + + if (!categoryId) { + throw new BadRequestException( + 'Product must be assigned to a category before filling technical info', + ); + } + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + + if (!form) { + throw new BadRequestException( + 'No technical form is defined for this product category', + ); + } + + const fieldMap = new Map(form.fields.map((field) => [field.key, field])); + const submittedKeys = new Set(); + + for (const entry of dto.values) { + if (submittedKeys.has(entry.fieldKey)) { + throw new BadRequestException( + `Duplicate value for field "${entry.fieldKey}"`, + ); + } + submittedKeys.add(entry.fieldKey); + + const field = fieldMap.get(entry.fieldKey); + if (!field) { + throw new BadRequestException( + `Unknown field key "${entry.fieldKey}"`, + ); + } + + this.validateFieldValue(field, entry.value); + } + + for (const field of form.fields) { + if (field.isRequired && !submittedKeys.has(field.key)) { + throw new BadRequestException( + `Required field "${field.label}" is missing`, + ); + } + } + + await this.prisma.$transaction(async (tx) => { + await tx.productTechnicalFieldValue.deleteMany({ + where: { businessId, productId: product.id }, + }); + + for (const entry of dto.values) { + const field = fieldMap.get(entry.fieldKey)!; + const fieldId = BigInt(field.id); + + if (entry.value === null || entry.value === undefined || entry.value === '') { + continue; + } + + if (field.type === 'text' || field.type === 'textarea') { + await tx.productTechnicalFieldValue.create({ + data: { + businessId, + productId: product.id, + fieldId, + textValue: String(entry.value), + }, + }); + continue; + } + + if (field.type === 'select') { + const option = this.findOptionByValue(field, entry.value as string); + await tx.productTechnicalFieldValue.create({ + data: { + businessId, + productId: product.id, + fieldId, + optionId: BigInt(option.id), + }, + }); + continue; + } + + const selectedValues = entry.value as string[]; + const fieldValue = await tx.productTechnicalFieldValue.create({ + data: { + businessId, + productId: product.id, + fieldId, + }, + }); + + for (const rawValue of selectedValues) { + const option = this.findOptionByValue(field, rawValue); + await tx.productTechnicalFieldValueOption.create({ + data: { + fieldValueId: fieldValue.id, + optionId: BigInt(option.id), + }, + }); + } + } + }); + + return this.getForProduct(businessIdRaw, productIdRaw, actor); + } + + private validateFieldValue( + field: FormField, + value: string | string[] | null | undefined, + ) { + if (value === null || value === undefined || value === '') { + if (field.isRequired) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + + if (field.type === 'text' || field.type === 'textarea') { + if (typeof value !== 'string') { + throw new BadRequestException( + `Field "${field.label}" expects a text value`, + ); + } + return; + } + + if (field.type === 'select') { + if (typeof value !== 'string') { + throw new BadRequestException( + `Field "${field.label}" expects a single option value`, + ); + } + this.findOptionByValue(field, value); + return; + } + + if (!Array.isArray(value) || !value.length) { + throw new BadRequestException( + `Field "${field.label}" expects an array of option values`, + ); + } + + for (const item of value) { + if (typeof item !== 'string') { + throw new BadRequestException( + `Field "${field.label}" expects string option values`, + ); + } + this.findOptionByValue(field, item); + } + } + + private findOptionByValue(field: FormField, rawValue: string) { + const normalized = rawValue.trim().toLowerCase(); + const option = + field.options.find((item) => item.value === rawValue) ?? + field.options.find((item) => item.value.toLowerCase() === normalized) ?? + field.options.find((item) => item.label.toLowerCase() === normalized); + + if (!option) { + throw new BadRequestException( + `Invalid option "${rawValue}" for field "${field.label}"`, + ); + } + + return option; + } + + private async loadProductValues( + businessId: bigint, + productId: bigint, + fields: FormField[], + ) { + const fieldIds = fields.map((field) => BigInt(field.id)); + + const stored = await this.prisma.productTechnicalFieldValue.findMany({ + where: { + businessId, + productId, + fieldId: { in: fieldIds }, + }, + include: { + option: true, + selectedOptions: { include: { option: true } }, + field: true, + }, + }); + + const byFieldId = new Map( + stored.map((item) => [item.fieldId.toString(), item]), + ); + + return fields.map((field) => { + const record = byFieldId.get(field.id); + if (!record) { + return { + fieldKey: field.key, + fieldLabel: field.label, + type: field.type, + value: field.type === 'multi_select' ? [] : null, + }; + } + + if (field.type === 'text' || field.type === 'textarea') { + return { + fieldKey: field.key, + fieldLabel: field.label, + type: field.type, + value: record.textValue, + }; + } + + if (field.type === 'select') { + return { + fieldKey: field.key, + fieldLabel: field.label, + type: field.type, + value: record.option?.value ?? null, + optionLabel: record.option?.label ?? null, + }; + } + + return { + fieldKey: field.key, + fieldLabel: field.label, + type: field.type, + value: record.selectedOptions.map((item) => item.option.value), + optionLabels: record.selectedOptions.map((item) => item.option.label), + }; + }); + } + + private async getProductWithCategory(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const assignment = await this.prisma.categoryAssignment.findFirst({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }); + + return { + product, + categoryId: assignment?.categoryId ?? null, + }; + } + + private async assertPublishedProductExists(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/products/product-variation-values.service.ts b/src/products/product-variation-values.service.ts new file mode 100644 index 0000000..c213490 --- /dev/null +++ b/src/products/product-variation-values.service.ts @@ -0,0 +1,251 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto'; + +@Injectable() +export class ProductVariationValuesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async getForProduct( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + return this.buildVariationValuesForProduct(businessId, productId); + } + + async getPublicForProduct(businessIdRaw: string, productIdRaw: string) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + + await this.assertPublishedProductExists(businessId, productId); + + return this.buildVariationValuesForProduct(businessId, productId); + } + + private async buildVariationValuesForProduct( + businessId: bigint, + productId: bigint, + ) { + const { categoryId } = await this.getProductWithCategory(businessId, productId); + + if (!categoryId) { + return { + variations: [], + message: 'Product has no category assigned', + }; + } + + const [categoryVariations, selectedValues] = await Promise.all([ + this.prisma.categoryVariation.findMany({ + where: { businessId, categoryId }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: { + options: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }, + }, + }), + this.prisma.productVariationValue.findMany({ + where: { productId }, + }), + ]); + + const selectedByVariation = new Map(); + for (const item of selectedValues) { + const key = item.variationId.toString(); + const list = selectedByVariation.get(key) ?? []; + list.push(item.optionId.toString()); + selectedByVariation.set(key, list); + } + + return { + variations: categoryVariations.map((variation) => ({ + id: variation.id.toString(), + name: variation.name, + type: variation.variationType, + sortOrder: variation.sortOrder, + options: variation.options.map((option) => ({ + id: option.id.toString(), + label: option.label, + value: option.value, + colorHex: option.colorHex, + sortOrder: option.sortOrder, + })), + selectedOptionIds: selectedByVariation.get(variation.id.toString()) ?? [], + })), + }; + } + + async replaceForProduct( + businessIdRaw: string, + productIdRaw: string, + dto: ReplaceProductVariationValuesDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const { categoryId } = await this.getProductWithCategory(businessId, productId); + + if (!categoryId) { + throw new BadRequestException( + 'Product must be assigned to a category before managing variations', + ); + } + + const categoryVariations = await this.prisma.categoryVariation.findMany({ + where: { businessId, categoryId }, + include: { options: true }, + }); + + const variationMap = new Map( + categoryVariations.map((variation) => [variation.id.toString(), variation]), + ); + + const optionToVariation = new Map(); + for (const variation of categoryVariations) { + for (const option of variation.options) { + optionToVariation.set(option.id.toString(), variation.id.toString()); + } + } + + const seenOptionIds = new Set(); + const rows: { productId: bigint; variationId: bigint; optionId: bigint }[] = []; + + for (const selection of dto.selections) { + const variation = variationMap.get(selection.variationId); + if (!variation) { + throw new BadRequestException( + `Variation "${selection.variationId}" is not defined for this product category`, + ); + } + + const validOptionIds = new Set( + variation.options.map((option) => option.id.toString()), + ); + + for (const optionId of selection.optionIds) { + if (seenOptionIds.has(optionId)) { + throw new BadRequestException(`Duplicate option "${optionId}" in request`); + } + seenOptionIds.add(optionId); + + if (!validOptionIds.has(optionId)) { + throw new BadRequestException( + `Option "${optionId}" does not belong to variation "${variation.name}"`, + ); + } + + const ownerVariationId = optionToVariation.get(optionId); + if (ownerVariationId !== selection.variationId) { + throw new BadRequestException( + `Option "${optionId}" does not belong to variation "${variation.name}"`, + ); + } + + rows.push({ + productId, + variationId: variation.id, + optionId: BigInt(optionId), + }); + } + } + + await this.prisma.$transaction(async (tx) => { + await tx.productVariationValue.deleteMany({ where: { productId } }); + + if (rows.length > 0) { + await tx.productVariationValue.createMany({ data: rows }); + } + }); + + return this.getForProduct(businessIdRaw, productIdRaw, actor); + } + + async countForProducts(businessId: bigint, productIds: bigint[]) { + if (productIds.length === 0) { + return new Map(); + } + + const rows = await this.prisma.productVariationValue.groupBy({ + by: ['productId'], + where: { productId: { in: productIds } }, + _count: { optionId: true }, + }); + + return new Map( + rows.map((row) => [row.productId.toString(), row._count.optionId]), + ); + } + + private async getProductWithCategory(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const assignment = await this.prisma.categoryAssignment.findFirst({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }); + + return { + product, + categoryId: assignment?.categoryId ?? null, + }; + } + + private async assertPublishedProductExists(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts new file mode 100644 index 0000000..5213e49 --- /dev/null +++ b/src/products/products.controller.ts @@ -0,0 +1,192 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CreateProductByAiDto } from './dto/product-ai.dto'; +import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto'; +import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto'; +import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto'; +import { ProductAiService } from './product-ai.service'; +import { ProductTechnicalInfoService } from './product-technical-info.service'; +import { ProductVariationValuesService } from './product-variation-values.service'; +import { ProductsService } from './products.service'; + +@Controller('businesses/:businessId/products') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class ProductsController { + constructor( + private readonly service: ProductsService, + private readonly aiService: ProductAiService, + private readonly variationValuesService: ProductVariationValuesService, + private readonly technicalInfoService: ProductTechnicalInfoService, + ) {} + + @Get() + @RequireBusinessPermission('products.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListProductsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Post('ai-create') + @RequireBusinessPermission('products.create') + createByAi( + @Param('businessId') businessId: string, + @Body() dto: CreateProductByAiDto, + @CurrentUser() user: AuthUser, + ) { + return this.aiService.createFromAi(businessId, dto, user); + } + + @Get(':productId') + @RequireBusinessPermission('products.read') + getOne( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, productId, user); + } + + @Post() + @RequireBusinessPermission('products.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateProductDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':productId') + @RequireBusinessPermission('products.update') + update( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @Body() dto: UpdateProductDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, productId, dto, user); + } + + @Delete(':productId') + @RequireBusinessPermission('products.delete') + remove( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, productId, user); + } + + @Get(':productId/variations') + @RequireBusinessPermission('products.read') + getVariationValues( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.variationValuesService.getForProduct(businessId, productId, user); + } + + @Put(':productId/variations') + @RequireBusinessPermission('products.update') + replaceVariationValues( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @Body() dto: ReplaceProductVariationValuesDto, + @CurrentUser() user: AuthUser, + ) { + return this.variationValuesService.replaceForProduct( + businessId, + productId, + dto, + user, + ); + } + + @Get(':productId/technical-info') + @RequireBusinessPermission('products.read') + getTechnicalInfo( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.technicalInfoService.getForProduct(businessId, productId, user); + } + + @Put(':productId/technical-info') + @RequireBusinessPermission('products.update') + replaceTechnicalInfo( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @Body() dto: ReplaceProductTechnicalInfoDto, + @CurrentUser() user: AuthUser, + ) { + return this.technicalInfoService.replaceForProduct( + businessId, + productId, + dto, + user, + ); + } +} + +@Controller('tenants/:host/products') +export class PublicProductsController { + constructor( + private readonly service: ProductsService, + private readonly variationValuesService: ProductVariationValuesService, + private readonly technicalInfoService: ProductTechnicalInfoService, + ) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicProductsDto) { + return this.service.listPublic(host, query); + } + + @Get(':slug/variations') + async getVariations(@Param('host') host: string, @Param('slug') slug: string) { + const { businessId, productId } = await this.service.assertPublishedProductBySlug( + host, + slug, + ); + return this.variationValuesService.getPublicForProduct( + businessId.toString(), + productId.toString(), + ); + } + + @Get(':slug/technical-info') + async getTechnicalInfo(@Param('host') host: string, @Param('slug') slug: string) { + const { businessId, productId } = await this.service.assertPublishedProductBySlug( + host, + slug, + ); + return this.technicalInfoService.getPublicForProduct( + businessId.toString(), + productId.toString(), + ); + } + + @Get(':slug') + getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + return this.service.getPublicBySlug(host, slug); + } +} diff --git a/src/products/products.module.ts b/src/products/products.module.ts new file mode 100644 index 0000000..f46ce65 --- /dev/null +++ b/src/products/products.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { BrandsModule } from '../brands/brands.module'; +import { CategoriesModule } from '../categories/categories.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { ProductAiService } from './product-ai.service'; +import { ProductTechnicalInfoService } from './product-technical-info.service'; +import { ProductVariationValuesService } from './product-variation-values.service'; +import { ProductsController, PublicProductsController } from './products.controller'; +import { ProductsService } from './products.service'; + +@Module({ + imports: [AuthModule, BrandsModule, CategoriesModule, TenantModule], + controllers: [ProductsController, PublicProductsController], + providers: [ + ProductsService, + ProductAiService, + ProductTechnicalInfoService, + ProductVariationValuesService, + ], + exports: [ProductVariationValuesService], +}) +export class ProductsModule {} diff --git a/src/products/products.service.ts b/src/products/products.service.ts new file mode 100644 index 0000000..85b29ee --- /dev/null +++ b/src/products/products.service.ts @@ -0,0 +1,810 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { BrandsService } from '../brands/brands.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto'; +import { ProductVariationValuesService } from './product-variation-values.service'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'product' + ); +} + +type ProductWithRelations = Prisma.ProductGetPayload<{ + include: { + featuredMedia: true; + brand: { include: { imageMedia: true } }; + }; +}>; + +@Injectable() +export class ProductsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly productVariationValues: ProductVariationValuesService, + private readonly brands: BrandsService, + private readonly tenant: TenantService, + ) {} + + async list(businessIdRaw: string, query: ListProductsDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + + const where: Prisma.ProductWhereInput = { + businessId, + ...(query.status ? { status: query.status } : {}), + ...(query.name?.trim() + ? { + OR: [ + { title: { contains: query.name.trim(), mode: 'insensitive' } }, + { + content: { + path: ['nameFa'], + string_contains: query.name.trim(), + }, + }, + ], + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.product.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + skip, + take: pageSize, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }), + this.prisma.product.count({ where }), + ]); + + const variantCounts = await this.productVariationValues.countForProducts( + businessId, + items.map((item) => item.id), + ); + + const serialized = await Promise.all( + items.map((item) => + this.serializeProduct(item, variantCounts.get(item.id.toString()) ?? 0), + ), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getOne(businessIdRaw: string, productIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + return { product: await this.serializeProduct(product, undefined) }; + } + + async listPublic(host: string, query: ListPublicProductsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + const skip = (page - 1) * pageSize; + const where = await this.buildPublicWhere(businessId, query); + + const [items, total] = await Promise.all([ + this.prisma.product.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }], + skip, + take: pageSize, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }), + this.prisma.product.count({ where }), + ]); + + const storeSummaries = await this.loadStoreSummariesForProducts( + businessId, + items.map((item) => item.id), + ); + + const serialized = await Promise.all( + items.map(async (item) => { + const product = await this.serializeProduct(item, undefined, { + approvedCommentsOnly: true, + }); + const store = storeSummaries.get(item.id.toString()); + return { + ...product, + store: store ?? { + variantCount: 0, + minPrice: null, + maxPrice: null, + inStock: false, + }, + }; + }), + ); + + return { items: serialized, total, page, pageSize }; + } + + async getPublicBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const product = await this.prisma.product.findFirst({ + where: { + businessId, + slug, + status: ContentStatus.published, + }, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]); + const serialized = await this.serializeProduct(product, undefined, { + approvedCommentsOnly: true, + }); + + return { + product: { + ...serialized, + store: storeSummaries.get(product.id.toString()) ?? { + variantCount: 0, + minPrice: null, + maxPrice: null, + inStock: false, + }, + }, + }; + } + + async assertPublishedProductBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const product = await this.prisma.product.findFirst({ + where: { + businessId: business.id, + slug, + status: ContentStatus.published, + }, + select: { id: true, businessId: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + return { businessId: product.businessId, productId: product.id }; + } + + async create(businessIdRaw: string, dto: CreateProductDto, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.create'); + + const slug = await this.ensureUniqueSlug( + businessId, + dto.slug ?? slugify(dto.title), + ); + + const status = dto.status ?? ContentStatus.published; + const featuredMediaId = dto.featuredMediaId + ? BigInt(dto.featuredMediaId) + : null; + + if (featuredMediaId) { + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds ?? [], + ); + + if (dto.categoryId) { + await this.assertCategoryBelongsToBusiness(businessId, BigInt(dto.categoryId)); + } + + let brandId: bigint | null = null; + if (dto.brandId) { + brandId = BigInt(dto.brandId); + await this.brands.assertBrandBelongsToBusiness(businessId, brandId); + } + + const created = await this.prisma.$transaction(async (tx) => { + const product = await tx.product.create({ + data: { + businessId, + title: dto.title.trim(), + slug, + description: dto.summary?.trim() || null, + content: this.buildContent(dto.nameFa, dto.descriptionHtml), + status, + featuredMediaId, + brandId, + publishedAt: status === ContentStatus.published ? new Date() : null, + metadata: this.buildMetadata(dto.tags), + }, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }); + + if (dto.categoryId) { + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId: BigInt(dto.categoryId), + entityType: MediaEntityType.product, + entityId: product.id, + }, + }); + } + + await this.syncGalleryAttachments( + tx, + businessId, + product.id, + galleryMediaIds, + ); + + return product; + }); + + return { + message: 'Product created successfully', + product: await this.serializeProduct(created), + }; + } + + async update( + businessIdRaw: string, + productIdRaw: string, + dto: UpdateProductDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const existing = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Product not found'); + } + + let slug = existing.slug; + if (dto.slug) { + slug = await this.ensureUniqueSlug(businessId, dto.slug, productId); + } else if (dto.title && dto.title !== existing.title) { + slug = await this.ensureUniqueSlug(businessId, slugify(dto.title), productId); + } + + let featuredMediaId: bigint | null | undefined = undefined; + if (dto.featuredMediaId !== undefined) { + if (dto.featuredMediaId === null || dto.featuredMediaId === '') { + featuredMediaId = null; + } else { + featuredMediaId = BigInt(dto.featuredMediaId); + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + } + + const existingContent = this.asRecord(existing.content); + const existingMetadata = this.asRecord(existing.metadata); + + const nextContent = { ...existingContent }; + if (dto.nameFa !== undefined) { + nextContent.nameFa = dto.nameFa?.trim() || null; + } + if (dto.descriptionHtml !== undefined) { + nextContent.html = dto.descriptionHtml ?? ''; + } + + const nextMetadata = { ...existingMetadata }; + if (dto.tags !== undefined) { + nextMetadata.tags = dto.tags; + } + + let publishedAt: Date | null | undefined = undefined; + if (dto.status !== undefined) { + if (dto.status === ContentStatus.published && existing.status !== ContentStatus.published) { + publishedAt = new Date(); + } + if (dto.status !== ContentStatus.published) { + publishedAt = null; + } + } + + let brandId: bigint | null | undefined = undefined; + if (dto.brandId !== undefined) { + if (dto.brandId === null || dto.brandId === '') { + brandId = null; + } else { + brandId = BigInt(dto.brandId); + await this.brands.assertBrandBelongsToBusiness(businessId, brandId); + } + } + + const updated = await this.prisma.$transaction(async (tx) => { + const product = await tx.product.update({ + where: { id: productId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.summary !== undefined + ? { description: dto.summary?.trim() || null } + : {}), + ...(dto.status !== undefined ? { status: dto.status } : {}), + ...(featuredMediaId !== undefined ? { featuredMediaId } : {}), + ...(brandId !== undefined ? { brandId } : {}), + ...(publishedAt !== undefined ? { publishedAt } : {}), + slug, + content: nextContent as Prisma.InputJsonValue, + metadata: nextMetadata as Prisma.InputJsonValue, + }, + include: { + featuredMedia: true, + brand: { include: { imageMedia: true } }, + }, + }); + + if (dto.categoryId !== undefined) { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }); + + if (dto.categoryId) { + const categoryId = BigInt(dto.categoryId); + await this.assertCategoryBelongsToBusiness(businessId, categoryId); + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }); + } + } + + if (dto.galleryMediaIds !== undefined) { + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds, + ); + await this.syncGalleryAttachments( + tx, + businessId, + productId, + galleryMediaIds, + ); + } + + return product; + }); + + return { + message: 'Product updated successfully', + product: await this.serializeProduct(updated), + }; + } + + async remove(businessIdRaw: string, productIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.delete'); + + const existing = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Product not found'); + } + + await this.prisma.$transaction([ + this.prisma.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }), + this.prisma.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }), + this.prisma.product.delete({ where: { id: productId } }), + ]); + + return { message: 'Product deleted successfully' }; + } + + private async serializeProduct( + product: ProductWithRelations, + variantCount?: number, + options: { approvedCommentsOnly?: boolean } = {}, + ) { + const content = this.asRecord(product.content); + const metadata = this.asRecord(product.metadata); + + const [categoryAssignment, galleryAttachments, resolvedVariantCount, commentCount] = + await Promise.all([ + this.prisma.categoryAssignment.findFirst({ + where: { + businessId: product.businessId, + entityType: MediaEntityType.product, + entityId: product.id, + }, + include: { category: true }, + }), + this.prisma.mediaAttachment.findMany({ + where: { + businessId: product.businessId, + entityType: MediaEntityType.product, + entityId: product.id, + isFeatured: false, + }, + orderBy: { sortOrder: 'asc' }, + include: { media: true }, + }), + variantCount === undefined + ? this.prisma.productVariationValue.count({ + where: { productId: product.id }, + }) + : Promise.resolve(variantCount), + this.prisma.comment.count({ + where: { + businessId: product.businessId, + entityType: MediaEntityType.product, + entityId: product.id, + ...(options.approvedCommentsOnly ? { isApproved: true } : {}), + }, + }), + ]); + + const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; + const image = thumbnailUrl ?? galleryAttachments[0]?.media.publicUrl ?? ''; + + return { + id: product.id.toString(), + businessId: product.businessId.toString(), + title: product.title, + nameFa: (content.nameFa as string | null | undefined) ?? '', + summary: product.description ?? '', + descriptionHtml: (content.html as string | undefined) ?? '', + slug: product.slug, + status: product.status, + categoryId: categoryAssignment?.categoryId.toString() ?? null, + categoryName: categoryAssignment?.category.name ?? '', + brandId: product.brandId?.toString() ?? null, + brand: this.brands.serializeBrandSummary(product.brand), + tags: Array.isArray(metadata.tags) + ? (metadata.tags as string[]) + : [], + thumbnailUrl, + thumbnailMediaId: product.featuredMediaId?.toString() ?? null, + image, + thumbnail: thumbnailUrl ?? image, + images: galleryAttachments.map((item) => ({ + mediaId: item.mediaId.toString(), + url: item.media.publicUrl, + })), + galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()), + commentCount, + variantCount: resolvedVariantCount, + createdAt: product.createdAt, + updatedAt: product.updatedAt, + }; + } + + private buildContent(nameFa?: string, descriptionHtml?: string) { + return { + nameFa: nameFa?.trim() || null, + html: descriptionHtml ?? '', + }; + } + + private buildMetadata(tags?: string[]) { + return { + tags: tags?.map((tag) => tag.trim()).filter(Boolean) ?? [], + }; + } + + private asRecord(value: Prisma.JsonValue): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async syncGalleryAttachments( + tx: Prisma.TransactionClient, + businessId: bigint, + productId: bigint, + mediaIds: bigint[], + ) { + await tx.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + isFeatured: false, + }, + }); + + for (const [index, mediaId] of mediaIds.entries()) { + await tx.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.product, + entityId: productId, + sortOrder: index, + isFeatured: false, + }, + }); + } + } + + private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { + const ids = rawIds.map((id) => BigInt(id)); + for (const mediaId of ids) { + await this.assertMediaBelongsToBusiness(businessId, mediaId); + } + return ids; + } + + private async assertMediaBelongsToBusiness(businessId: bigint, mediaId: bigint) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + if (!media) { + throw new BadRequestException('Media not found for this business'); + } + } + + private async assertCategoryBelongsToBusiness( + businessId: bigint, + categoryId: bigint, + ) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.product, + isActive: true, + }, + }); + if (!category) { + throw new BadRequestException('Category not found for this business'); + } + } + + private async buildPublicWhere( + businessId: bigint, + query: ListPublicProductsDto, + ): Promise { + let entityIds: bigint[] | undefined; + + if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.product, + }, + select: { entityId: true }, + }); + + entityIds = assignments.map((item) => item.entityId); + if (entityIds.length === 0) { + return { id: { in: [] } }; + } + } + + const tag = query.tag?.trim(); + const name = query.name?.trim(); + + return { + businessId, + status: ContentStatus.published, + ...(query.brandId ? { brandId: BigInt(query.brandId) } : {}), + ...(entityIds ? { id: { in: entityIds } } : {}), + ...(tag + ? { + metadata: { + path: ['tags'], + array_contains: tag, + }, + } + : {}), + ...(name + ? { + OR: [ + { title: { contains: name, mode: 'insensitive' } }, + { + content: { + path: ['nameFa'], + string_contains: name, + }, + }, + ], + } + : {}), + ...(query.inStore + ? { + storeItem: { + some: { + isActive: true, + variants: { some: { isActive: true } }, + }, + }, + } + : {}), + }; + } + + private async loadStoreSummariesForProducts( + businessId: bigint, + productIds: bigint[], + ) { + if (productIds.length === 0) { + return new Map< + string, + { + variantCount: number; + minPrice: number | null; + maxPrice: number | null; + inStock: boolean; + } + >(); + } + + const variants = await this.prisma.storeItemVariant.findMany({ + where: { + businessId, + isActive: true, + storeItem: { + isActive: true, + productId: { in: productIds }, + product: { status: ContentStatus.published }, + }, + }, + select: { + price: true, + stockQuantity: true, + storeItem: { select: { productId: true } }, + }, + }); + + const map = new Map< + string, + { + variantCount: number; + minPrice: number | null; + maxPrice: number | null; + inStock: boolean; + } + >(); + + for (const variant of variants) { + const productId = variant.storeItem.productId.toString(); + const entry = map.get(productId) ?? { + variantCount: 0, + minPrice: null, + maxPrice: null, + inStock: false, + }; + + entry.variantCount += 1; + const price = variant.price === null ? null : Number(variant.price); + if (price !== null) { + entry.minPrice = + entry.minPrice === null ? price : Math.min(entry.minPrice, price); + entry.maxPrice = + entry.maxPrice === null ? price : Math.max(entry.maxPrice, price); + } + if ((variant.stockQuantity ?? 0) > 0) { + entry.inStock = true; + } + + map.set(productId, entry); + } + + return map; + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.product.findFirst({ + where: { + businessId, + slug, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException(`Missing permission: ${permission} for this business`); + } + } +} diff --git a/src/redis/redis.constants.ts b/src/redis/redis.constants.ts new file mode 100644 index 0000000..6fddc65 --- /dev/null +++ b/src/redis/redis.constants.ts @@ -0,0 +1 @@ +export const REDIS_CLIENT = 'REDIS_CLIENT'; diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts new file mode 100644 index 0000000..cf28851 --- /dev/null +++ b/src/redis/redis.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { REDIS_CLIENT } from './redis.constants'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + inject: [ConfigService], + useFactory: (config: ConfigService) => { + return new Redis(config.get('REDIS_URL', 'redis://localhost:6379')); + }, + }, + RedisService, + ], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts new file mode 100644 index 0000000..bf08bb7 --- /dev/null +++ b/src/redis/redis.service.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS_CLIENT } from './redis.constants'; + +@Injectable() +export class RedisService { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + get client(): Redis { + return this.redis; + } + + async setOtp(cellNumber: string, code: string, ttlSeconds: number): Promise { + await this.redis.set(`otp:${cellNumber}`, code, 'EX', ttlSeconds); + } + + async getOtp(cellNumber: string): Promise { + return this.redis.get(`otp:${cellNumber}`); + } + + async deleteOtp(cellNumber: string): Promise { + await this.redis.del(`otp:${cellNumber}`); + } +} diff --git a/src/roles/dto/list-roles.dto.ts b/src/roles/dto/list-roles.dto.ts new file mode 100644 index 0000000..223b4c1 --- /dev/null +++ b/src/roles/dto/list-roles.dto.ts @@ -0,0 +1,7 @@ +import { IsIn, IsOptional } from 'class-validator'; + +export class ListRolesDto { + @IsOptional() + @IsIn(['global', 'team']) + scope?: 'global' | 'team' = 'global'; +} diff --git a/src/roles/roles.controller.ts b/src/roles/roles.controller.ts new file mode 100644 index 0000000..e395005 --- /dev/null +++ b/src/roles/roles.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { ListRolesDto } from './dto/list-roles.dto'; +import { RolesService } from './roles.service'; + +@Controller('roles') +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @UseGuards(JwtAuthGuard) + list(@Query() query: ListRolesDto, @CurrentUser() user: AuthUser) { + return this.rolesService.list(query, user); + } +} diff --git a/src/roles/roles.module.ts b/src/roles/roles.module.ts new file mode 100644 index 0000000..32710ab --- /dev/null +++ b/src/roles/roles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { RolesController } from './roles.controller'; +import { RolesService } from './roles.service'; + +@Module({ + imports: [AuthModule], + controllers: [RolesController], + providers: [RolesService], +}) +export class RolesModule {} diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts new file mode 100644 index 0000000..8be71c0 --- /dev/null +++ b/src/roles/roles.service.ts @@ -0,0 +1,66 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { + ASSIGNABLE_GLOBAL_ROLES, + ASSIGNABLE_TEAM_ROLES, + AuthUser, +} from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ListRolesDto } from './dto/list-roles.dto'; + +@Injectable() +export class RolesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list(query: ListRolesDto, actor: AuthUser) { + const scope = query.scope ?? 'global'; + + if (scope === 'global') { + if (!(await this.permissions.isSuperAdmin(actor.id))) { + throw new ForbiddenException('Super admin access required'); + } + + const roles = await this.prisma.role.findMany({ + where: { slug: { in: [...ASSIGNABLE_GLOBAL_ROLES] } }, + orderBy: { name: 'asc' }, + select: { id: true, slug: true, name: true, description: true }, + }); + + return { scope, items: roles }; + } + + const canListTeamRoles = await this.canListTeamRoles(actor); + if (!canListTeamRoles) { + throw new ForbiddenException('You cannot list team roles'); + } + + const roles = await this.prisma.role.findMany({ + where: { slug: { in: [...ASSIGNABLE_TEAM_ROLES] } }, + orderBy: { name: 'asc' }, + select: { id: true, slug: true, name: true, description: true }, + }); + + return { scope, items: roles }; + } + + private async canListTeamRoles(actor: AuthUser): Promise { + if (await this.permissions.isSuperAdmin(actor.id)) { + return true; + } + + if (actor.roles.includes('business_owner')) { + return true; + } + + for (const business of actor.businesses) { + if (business.permissions.includes('business.team.read')) { + return true; + } + } + + return false; + } +} diff --git a/src/shopping-cards/dto/shopping-card.dto.ts b/src/shopping-cards/dto/shopping-card.dto.ts new file mode 100644 index 0000000..9c92248 --- /dev/null +++ b/src/shopping-cards/dto/shopping-card.dto.ts @@ -0,0 +1,73 @@ +import { + ArrayMinSize, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ShoppingCardItemInputDto { + @IsString() + @MinLength(1) + storeItemVariantId!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + quantity!: number; +} + +export class CreateShoppingCardDto { + @IsString() + @MinLength(1) + customerUserId!: string; + + @ValidateNested({ each: true }) + @Type(() => ShoppingCardItemInputDto) + @ArrayMinSize(1) + items!: ShoppingCardItemInputDto[]; +} + +export class ListShoppingCardsDto { + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + pageSize?: number; + + @IsOptional() + @IsString() + @MinLength(1) + customerQuery?: string; + + @IsOptional() + @IsDateString() + dateFrom?: string; + + @IsOptional() + @IsDateString() + dateTo?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + minTotal?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + maxTotal?: number; +} diff --git a/src/shopping-cards/shopping-cards.controller.ts b/src/shopping-cards/shopping-cards.controller.ts new file mode 100644 index 0000000..f5b8fe3 --- /dev/null +++ b/src/shopping-cards/shopping-cards.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateShoppingCardDto, + ListShoppingCardsDto, +} from './dto/shopping-card.dto'; +import { ShoppingCardsService } from './shopping-cards.service'; + +@Controller('businesses/:businessId/shopping-cards') +@UseGuards(JwtAuthGuard) +export class ShoppingCardsController { + constructor(private readonly service: ShoppingCardsService) {} + + @Get() + list( + @Param('businessId') businessId: string, + @Query() query: ListShoppingCardsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':cardId') + getOne( + @Param('businessId') businessId: string, + @Param('cardId') cardId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, cardId, user); + } + + @Post() + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.create') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateShoppingCardDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Delete(':cardId') + @UseGuards(BusinessPermissionGuard) + @RequireBusinessPermission('orders.update') + remove( + @Param('businessId') businessId: string, + @Param('cardId') cardId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, cardId, user); + } +} diff --git a/src/shopping-cards/shopping-cards.module.ts b/src/shopping-cards/shopping-cards.module.ts new file mode 100644 index 0000000..4cce9cc --- /dev/null +++ b/src/shopping-cards/shopping-cards.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ShoppingCardsController } from './shopping-cards.controller'; +import { ShoppingCardsService } from './shopping-cards.service'; + +@Module({ + imports: [AuthModule], + controllers: [ShoppingCardsController], + providers: [ShoppingCardsService], + exports: [ShoppingCardsService], +}) +export class ShoppingCardsModule {} diff --git a/src/shopping-cards/shopping-cards.service.ts b/src/shopping-cards/shopping-cards.service.ts new file mode 100644 index 0000000..e9d9af3 --- /dev/null +++ b/src/shopping-cards/shopping-cards.service.ts @@ -0,0 +1,420 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateShoppingCardDto, + ListShoppingCardsDto, +} from './dto/shopping-card.dto'; + +type ShoppingCardWithItems = Prisma.ShoppingCardGetPayload<{ + include: { + items: true; + customer: { + select: { + id: true; + firstName: true; + lastName: true; + cellNumber: true; + email: true; + }; + }; + }; +}>; + +type PreparedShoppingCardItem = { + storeItemVariantId: bigint; + productId: bigint; + productTitle: string; + variantSku: string | null; + unitPrice: number; + compareAtPrice: number; + quantity: number; + lineTotal: number; + selectionsSnapshot: { + variationId: string; + variationName: string; + optionId: string; + value: string; + }[]; +}; + +@Injectable() +export class ShoppingCardsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async list( + businessIdRaw: string, + query: ListShoppingCardsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.ShoppingCardWhereInput = { + businessId, + ...(query.dateFrom || query.dateTo + ? { + createdAt: { + ...(query.dateFrom ? { gte: new Date(query.dateFrom) } : {}), + ...(query.dateTo + ? { + lte: (() => { + const end = new Date(query.dateTo); + end.setHours(23, 59, 59, 999); + return end; + })(), + } + : {}), + }, + } + : {}), + ...(query.minTotal !== undefined || query.maxTotal !== undefined + ? { + total: { + ...(query.minTotal !== undefined ? { gte: query.minTotal } : {}), + ...(query.maxTotal !== undefined ? { lte: query.maxTotal } : {}), + }, + } + : {}), + ...(query.customerQuery?.trim() + ? { + customer: this.buildCustomerSearchFilter(query.customerQuery.trim()), + } + : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.shoppingCard.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: { + items: true, + customer: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + email: true, + }, + }, + }, + }), + this.prisma.shoppingCard.count({ where }), + ]); + + return { + items: items.map((item) => this.serialize(item)), + total, + page, + pageSize, + }; + } + + async getOne(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const cardId = BigInt(cardIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.read'); + + const card = await this.prisma.shoppingCard.findFirst({ + where: { id: cardId, businessId }, + include: { + items: true, + customer: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + email: true, + }, + }, + }, + }); + + if (!card) { + throw new NotFoundException('Shopping card not found'); + } + + return { card: this.serialize(card) }; + } + + async create( + businessIdRaw: string, + dto: CreateShoppingCardDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.create'); + + const customerUserId = BigInt(dto.customerUserId); + await this.assertBusinessCustomer(businessId, customerUserId); + + const preparedItems = await this.prepareItems( + businessId, + dto.items.map((item) => ({ + storeItemVariantId: BigInt(item.storeItemVariantId), + quantity: item.quantity, + })), + ); + + const subtotal = preparedItems.reduce((sum, item) => sum + item.lineTotal, 0); + const total = subtotal; + + const created = await this.prisma.shoppingCard.create({ + data: { + businessId, + userId: customerUserId, + subtotal, + total, + createdBy: actor.id, + items: { + create: preparedItems.map((item) => ({ + storeItemVariantId: item.storeItemVariantId, + productId: item.productId, + productTitle: item.productTitle, + variantSku: item.variantSku, + unitPrice: item.unitPrice, + compareAtPrice: item.compareAtPrice, + quantity: item.quantity, + lineTotal: item.lineTotal, + selectionsSnapshot: item.selectionsSnapshot, + })), + }, + }, + include: { + items: true, + customer: { + select: { + id: true, + firstName: true, + lastName: true, + cellNumber: true, + email: true, + }, + }, + }, + }); + + return { + message: 'Shopping card saved', + card: this.serialize(created), + }; + } + + async remove(businessIdRaw: string, cardIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + const cardId = BigInt(cardIdRaw); + await this.assertPermission(businessId, actor.id, 'orders.update'); + + const existing = await this.prisma.shoppingCard.findFirst({ + where: { id: cardId, businessId }, + select: { id: true }, + }); + + if (!existing) { + throw new NotFoundException('Shopping card not found'); + } + + await this.prisma.shoppingCard.delete({ where: { id: cardId } }); + + return { message: 'Shopping card removed' }; + } + + private async prepareItems( + businessId: bigint, + items: { storeItemVariantId: bigint; quantity: number }[], + ) { + if (!items.length) { + throw new BadRequestException('At least one item is required'); + } + + const prepared: PreparedShoppingCardItem[] = []; + + for (const item of items) { + const variant = await this.prisma.storeItemVariant.findFirst({ + where: { + id: item.storeItemVariantId, + businessId, + isActive: true, + }, + include: { + storeItem: { + include: { + product: true, + }, + }, + selections: { + include: { + variation: true, + option: true, + }, + }, + }, + }); + + if (!variant) { + throw new NotFoundException( + `Store item variant ${item.storeItemVariantId.toString()} not found or unavailable`, + ); + } + + const product = variant.storeItem.product; + + if (product.status !== ContentStatus.published) { + throw new BadRequestException( + `Product "${product.title}" is not available for purchase`, + ); + } + + if (variant.price === null) { + throw new BadRequestException( + `Store item variant ${item.storeItemVariantId.toString()} has no price configured`, + ); + } + + const price = Number(variant.price); + const compareAtPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + const unitPrice = + compareAtPrice !== null && compareAtPrice < price ? compareAtPrice : price; + const selectionsSnapshot = variant.selections.map((selection) => ({ + variationId: selection.variation.id.toString(), + variationName: selection.variation.name, + optionId: selection.option.id.toString(), + value: selection.option.label, + })); + + prepared.push({ + storeItemVariantId: variant.id, + productId: product.id, + productTitle: product.title, + variantSku: variant.sku, + unitPrice, + compareAtPrice: price, + quantity: item.quantity, + lineTotal: unitPrice * item.quantity, + selectionsSnapshot, + }); + } + + return prepared; + } + + private serialize(card: ShoppingCardWithItems) { + return { + id: card.id.toString(), + businessId: card.businessId.toString(), + subtotal: Number(card.subtotal), + total: Number(card.total), + createdBy: card.createdBy?.toString() ?? null, + createdAt: card.createdAt, + updatedAt: card.updatedAt, + customer: { + id: card.customer.id.toString(), + firstName: card.customer.firstName, + lastName: card.customer.lastName, + cellNumber: card.customer.cellNumber, + email: card.customer.email, + }, + items: card.items.map((item) => ({ + id: item.id.toString(), + storeItemVariantId: item.storeItemVariantId?.toString() ?? null, + productId: item.productId.toString(), + productTitle: item.productTitle, + variantSku: item.variantSku, + unitPrice: Number(item.unitPrice), + compareAtPrice: + item.compareAtPrice === null ? null : Number(item.compareAtPrice), + quantity: item.quantity, + lineTotal: Number(item.lineTotal), + selections: this.readSelections(item.selectionsSnapshot), + })), + }; + } + + private readSelections(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .map((entry) => { + if (!entry || typeof entry !== 'object') return null; + const record = entry as Record; + const variationId = + typeof record.variationId === 'string' ? record.variationId : ''; + const variationName = + typeof record.variationName === 'string' ? record.variationName : ''; + const optionId = typeof record.optionId === 'string' ? record.optionId : ''; + const label = typeof record.value === 'string' ? record.value : ''; + if (!variationId || !optionId) return null; + return { variationId, variationName, optionId, value: label }; + }) + .filter((entry): entry is NonNullable => entry !== null); + } + + private buildCustomerSearchFilter(query: string): Prisma.UserWhereInput { + const digits = query.replace(/\D/g, ''); + const or: Prisma.UserWhereInput[] = [ + { firstName: { contains: query, mode: 'insensitive' } }, + { lastName: { contains: query, mode: 'insensitive' } }, + ]; + + if (digits.length >= 3) { + or.push({ cellNumber: { contains: digits } }); + if (digits.startsWith('0')) { + or.push({ cellNumber: { contains: `+98${digits.slice(1)}` } }); + } else if (digits.startsWith('98')) { + or.push({ cellNumber: { contains: `+${digits}` } }); + } else if (digits.length === 10 && digits.startsWith('9')) { + or.push({ cellNumber: { contains: `+98${digits}` } }); + } + } + + return { OR: or }; + } + + private async assertBusinessCustomer(businessId: bigint, userId: bigint) { + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId }, + }, + }); + + if (!membership) { + throw new BadRequestException('Customer is not registered for this business'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/storage/s3-storage.driver.ts b/src/storage/s3-storage.driver.ts new file mode 100644 index 0000000..fa08447 --- /dev/null +++ b/src/storage/s3-storage.driver.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { StoredObject, UploadObjectInput } from './storage.types'; + +@Injectable() +export class S3StorageDriver { + private readonly client: S3Client; + private readonly bucket: string; + private readonly publicUrlBase: string; + private readonly storageDisk = 's3'; + + constructor(private readonly config: ConfigService) { + const endpoint = this.config.getOrThrow('S3_ENDPOINT'); + const region = this.config.get('S3_REGION', 'us-east-1'); + const forcePathStyle = + this.config.get('S3_FORCE_PATH_STYLE', 'true') === 'true'; + + this.bucket = this.config.getOrThrow('S3_BUCKET'); + this.publicUrlBase = this.config + .getOrThrow('S3_PUBLIC_URL') + .replace(/\/$/, ''); + + this.client = new S3Client({ + endpoint, + region, + forcePathStyle, + credentials: { + accessKeyId: this.config.getOrThrow('S3_ACCESS_KEY_ID'), + secretAccessKey: this.config.getOrThrow('S3_SECRET_ACCESS_KEY'), + }, + }); + } + + async upload(input: UploadObjectInput): Promise { + const key = input.key.replace(/^\/+/, ''); + + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: input.body, + ContentType: input.contentType, + }), + ); + + return { + storageDisk: this.storageDisk, + storagePath: key, + publicUrl: `${this.publicUrlBase}/${key}`, + }; + } + + async getBuffer(storagePath: string): Promise { + const key = storagePath.replace(/^\/+/, ''); + const result = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + + if (!result.Body) { + throw new Error(`Empty object body for ${key}`); + } + + return Buffer.from(await result.Body.transformToByteArray()); + } + + async delete(storagePath: string): Promise { + const key = storagePath.replace(/^\/+/, ''); + + await this.client.send( + new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }), + ); + } +} diff --git a/src/storage/storage.module.ts b/src/storage/storage.module.ts new file mode 100644 index 0000000..2cceb5b --- /dev/null +++ b/src/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { S3StorageDriver } from './s3-storage.driver'; +import { StorageService } from './storage.service'; + +@Global() +@Module({ + providers: [S3StorageDriver, StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts new file mode 100644 index 0000000..bb290fa --- /dev/null +++ b/src/storage/storage.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { S3StorageDriver } from './s3-storage.driver'; +import { StoredObject, UploadObjectInput } from './storage.types'; + +@Injectable() +export class StorageService { + constructor(private readonly s3: S3StorageDriver) {} + + upload(input: UploadObjectInput): Promise { + return this.s3.upload(input); + } + + getBuffer(storagePath: string, storageDisk: string): Promise { + if (storageDisk !== 's3') { + return Promise.reject(new Error(`Unsupported storage disk: ${storageDisk}`)); + } + + return this.s3.getBuffer(storagePath); + } + + delete(storagePath: string, storageDisk: string): Promise { + if (storageDisk !== 's3') { + return Promise.resolve(); + } + + return this.s3.delete(storagePath); + } +} diff --git a/src/storage/storage.types.ts b/src/storage/storage.types.ts new file mode 100644 index 0000000..fa73bd8 --- /dev/null +++ b/src/storage/storage.types.ts @@ -0,0 +1,11 @@ +export interface StoredObject { + storageDisk: string; + storagePath: string; + publicUrl: string; +} + +export interface UploadObjectInput { + key: string; + body: Buffer; + contentType: string; +} diff --git a/src/store/dto/store-items.dto.ts b/src/store/dto/store-items.dto.ts new file mode 100644 index 0000000..ec29397 --- /dev/null +++ b/src/store/dto/store-items.dto.ts @@ -0,0 +1,223 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class StoreItemVariantSelectionDto { + @IsString() + @MinLength(1) + variationId!: string; + + @IsString() + @MinLength(1) + optionId!: string; +} + +export class CreateStoreItemVariantDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StoreItemVariantSelectionDto) + selections!: StoreItemVariantSelectionDto[]; + + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsInt() + @Min(0) + stockQuantity?: number; +} + +export class BatchCreateStoreItemsDto { + @IsString() + @MinLength(1) + productId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateStoreItemVariantDto) + items!: CreateStoreItemVariantDto[]; +} + +export class ListStoreItemsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} + +export class ListPublicStoreItemsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @IsString() + categoryId?: string; + + @IsOptional() + @IsString() + brandId?: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + inStock?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isFestival?: boolean; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + minPrice?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + maxPrice?: number; +} + +export class UpdateStoreItemVariantDto { + @IsOptional() + @IsNumber() + @Min(0) + price?: number; + + @IsOptional() + @IsInt() + @Min(0) + stockQuantity?: number; + + @IsOptional() + @IsNumber() + @Min(0) + discountedPrice?: number | null; + + @IsOptional() + isFestival?: boolean; +} + +export class BatchUpdateStoreItemDiscountDto { + @IsString() + @MinLength(1) + id!: string; + + @IsOptional() + @IsNumber() + @Min(0) + discountedPrice?: number | null; +} + +export class BatchUpdateStoreItemDiscountsDto { + @IsString() + @MinLength(1) + productId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BatchUpdateStoreItemDiscountDto) + items!: BatchUpdateStoreItemDiscountDto[]; +} + +export class SyncStoreItemVariantRowDto { + @IsOptional() + @IsString() + id?: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StoreItemVariantSelectionDto) + selections!: StoreItemVariantSelectionDto[]; + + @IsNumber() + @Min(0) + price!: number; + + @IsInt() + @Min(0) + stockQuantity!: number; +} + +export class SyncProductStoreItemVariantsDto { + @IsString() + @MinLength(1) + productId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SyncStoreItemVariantRowDto) + items!: SyncStoreItemVariantRowDto[]; + + @IsArray() + @IsString({ each: true }) + removedIds!: string[]; +} + +export class BatchUpdateFestivalRewardDto { + @IsString() + @MinLength(1) + id!: string; + + @IsOptional() + @IsInt() + @Min(0) + rewardPoints?: number | null; +} + +export class BatchUpdateFestivalRewardsDto { + @IsString() + @MinLength(1) + productId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => BatchUpdateFestivalRewardDto) + items!: BatchUpdateFestivalRewardDto[]; +} + +// Backward-compatible aliases for existing imports +export { + StoreItemVariantSelectionDto as StoreItemSelectionDto, + CreateStoreItemVariantDto as CreateStoreItemDto, + UpdateStoreItemVariantDto as UpdateStoreItemDto, + SyncStoreItemVariantRowDto as SyncStoreItemRowDto, + SyncProductStoreItemVariantsDto as SyncProductStoreItemsDto, +}; diff --git a/src/store/dto/store-specials.dto.ts b/src/store/dto/store-specials.dto.ts new file mode 100644 index 0000000..f350b9d --- /dev/null +++ b/src/store/dto/store-specials.dto.ts @@ -0,0 +1,71 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateStoreSpecialDto { + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + storeItemIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateStoreSpecialDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + storeItemIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ListStoreSpecialsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/store/store-items.controller.ts b/src/store/store-items.controller.ts new file mode 100644 index 0000000..0529c95 --- /dev/null +++ b/src/store/store-items.controller.ts @@ -0,0 +1,133 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + BatchCreateStoreItemsDto, + BatchUpdateStoreItemDiscountsDto, + BatchUpdateFestivalRewardsDto, + ListPublicStoreItemsDto, + ListStoreItemsDto, + SyncProductStoreItemVariantsDto, + UpdateStoreItemVariantDto, +} from './dto/store-items.dto'; +import { StoreItemsService } from './store-items.service'; + +@Controller('businesses/:businessId/store-items') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class StoreItemsController { + constructor(private readonly service: StoreItemsService) {} + + @Get() + @RequireBusinessPermission('products.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListStoreItemsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get('by-product/:productId') + @RequireBusinessPermission('products.read') + getByProduct( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getByProduct(businessId, productId, user); + } + + @Post() + @RequireBusinessPermission('products.update') + batchCreateVariants( + @Param('businessId') businessId: string, + @Body() dto: BatchCreateStoreItemsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.batchCreateVariants(businessId, dto, user); + } + + @Put('discounts') + @RequireBusinessPermission('products.update') + updateDiscounts( + @Param('businessId') businessId: string, + @Body() dto: BatchUpdateStoreItemDiscountsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateDiscounts(businessId, dto, user); + } + + @Put('sync') + @RequireBusinessPermission('products.update') + syncProductVariants( + @Param('businessId') businessId: string, + @Body() dto: SyncProductStoreItemVariantsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.syncProductVariants(businessId, dto, user); + } + + @Put('festival-rewards') + @RequireBusinessPermission('products.update') + updateFestivalRewards( + @Param('businessId') businessId: string, + @Body() dto: BatchUpdateFestivalRewardsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateFestivalRewards(businessId, dto, user); + } + + @Patch('variants/:variantId') + @RequireBusinessPermission('products.update') + updateVariant( + @Param('businessId') businessId: string, + @Param('variantId') variantId: string, + @Body() dto: UpdateStoreItemVariantDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.updateVariant(businessId, variantId, dto, user); + } + + @Delete('variants/:variantId') + @RequireBusinessPermission('products.update') + removeVariant( + @Param('businessId') businessId: string, + @Param('variantId') variantId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.removeVariant(businessId, variantId, user); + } + + @Delete('by-product/:productId') + @RequireBusinessPermission('products.update') + removeByProduct( + @Param('businessId') businessId: string, + @Param('productId') productId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.removeByProduct(businessId, productId, user); + } +} + +@Controller('tenants/:host/store-items') +export class PublicStoreItemsController { + constructor(private readonly service: StoreItemsService) {} + + @Get() + list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) { + return this.service.listPublic(host, query); + } + + @Get('by-product/:productId') + getByProduct(@Param('host') host: string, @Param('productId') productId: string) { + return this.service.getPublicByProduct(host, productId); + } + + @Get(':variantId') + getVariant(@Param('host') host: string, @Param('variantId') variantId: string) { + return this.service.getPublicVariant(host, variantId); + } +} diff --git a/src/store/store-items.service.ts b/src/store/store-items.service.ts new file mode 100644 index 0000000..b0e5da3 --- /dev/null +++ b/src/store/store-items.service.ts @@ -0,0 +1,1088 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + BatchCreateStoreItemsDto, + BatchUpdateStoreItemDiscountsDto, + BatchUpdateFestivalRewardsDto, + CreateStoreItemVariantDto, + ListPublicStoreItemsDto, + ListStoreItemsDto, + SyncProductStoreItemVariantsDto, + UpdateStoreItemVariantDto, +} from './dto/store-items.dto'; + +const variantInclude = { + storeItem: { + include: { + product: { + include: { + featuredMedia: true, + }, + }, + }, + }, + selections: { + include: { + variation: true, + option: true, + }, + }, +} satisfies Prisma.StoreItemVariantInclude; + +type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{ + include: typeof variantInclude; +}>; + +@Injectable() +export class StoreItemsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async list( + businessIdRaw: string, + query: ListStoreItemsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + const where = { businessId }; + + const [items, total] = await Promise.all([ + this.prisma.storeItemVariant.findMany({ + where, + orderBy: [{ createdAt: 'desc' }], + skip, + take: pageSize, + include: variantInclude, + }), + this.prisma.storeItemVariant.count({ where }), + ]); + + const productIds = [ + ...new Set(items.map((item) => item.storeItem.productId)), + ]; + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, productIds), + this.loadStockTotals(businessId, productIds), + ]); + + return { + items: items.map((item) => + this.serializeVariant(item, { + galleryUrl: + galleryByProduct.get(item.storeItem.productId.toString()) ?? null, + productTotalStock: + stockByProduct.get(item.storeItem.productId.toString()) ?? 0, + }), + ), + total, + page, + pageSize, + }; + } + + async listPublic(host: string, query: ListPublicStoreItemsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + const where = await this.buildPublicVariantWhere(businessId, query); + + const [items, total] = await Promise.all([ + this.prisma.storeItemVariant.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + skip, + take: pageSize, + include: variantInclude, + }), + this.prisma.storeItemVariant.count({ where }), + ]); + + const productIds = [...new Set(items.map((item) => item.storeItem.productId))]; + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, productIds), + this.loadStockTotals(businessId, productIds), + ]); + + return { + items: items.map((item) => + this.serializeVariant(item, { + galleryUrl: + galleryByProduct.get(item.storeItem.productId.toString()) ?? null, + productTotalStock: + stockByProduct.get(item.storeItem.productId.toString()) ?? 0, + }), + ), + total, + page, + pageSize, + }; + } + + async getPublicByProduct(host: string, productIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const productId = BigInt(productIdRaw); + + await this.assertPublishedProductExists(businessId, productId); + + const storeItem = await this.prisma.storeItem.findFirst({ + where: { businessId, productId, isActive: true }, + include: { + variants: { + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: variantInclude, + }, + }, + }); + + if (!storeItem) { + return { storeItem: null }; + } + + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + storeItem: { + id: storeItem.id.toString(), + productId: storeItem.productId.toString(), + isActive: storeItem.isActive, + sortOrder: storeItem.sortOrder, + createdAt: storeItem.createdAt, + updatedAt: storeItem.updatedAt, + variants: storeItem.variants.map((variant) => + this.serializeVariant(variant, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }, + }; + } + + async getPublicVariant(host: string, variantIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + const variantId = BigInt(variantIdRaw); + + const variant = await this.prisma.storeItemVariant.findFirst({ + where: { + id: variantId, + businessId, + isActive: true, + storeItem: { + isActive: true, + product: { status: ContentStatus.published }, + }, + }, + include: variantInclude, + }); + + if (!variant) { + throw new NotFoundException('Store item variant not found'); + } + + const productId = variant.storeItem.productId; + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + variant: this.serializeVariant(variant, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + }; + } + + async getByProduct( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const storeItem = await this.prisma.storeItem.findFirst({ + where: { businessId, productId }, + include: { + variants: { + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: variantInclude, + }, + }, + }); + + if (!storeItem) { + return { storeItem: null }; + } + + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + storeItem: { + id: storeItem.id.toString(), + productId: storeItem.productId.toString(), + isActive: storeItem.isActive, + sortOrder: storeItem.sortOrder, + createdAt: storeItem.createdAt, + updatedAt: storeItem.updatedAt, + variants: storeItem.variants.map((variant) => + this.serializeVariant(variant, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }, + }; + } + + async batchCreateVariants( + businessIdRaw: string, + dto: BatchCreateStoreItemsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(dto.productId); + await this.assertPermission(businessId, actor.id, 'products.update'); + + if (!dto.items.length) { + throw new BadRequestException('At least one variant is required'); + } + + await this.assertProductWithCategory(businessId, productId); + + const [categoryVariations, productValues, existingVariants] = + await this.loadVariantContext(businessId, productId); + + const variationMap = new Map( + categoryVariations.map((variation) => [variation.id.toString(), variation]), + ); + const allowedOptionIds = new Set( + productValues.map((item) => item.optionId.toString()), + ); + const existingKeys = new Set( + existingVariants.map((variant) => + this.selectionKey(variant.selections.map((s) => s.optionId.toString())), + ), + ); + const batchKeys = new Set(); + + for (const item of dto.items) { + this.validateVariantItem( + item, + variationMap, + allowedOptionIds, + existingKeys, + batchKeys, + ); + } + + const created = await this.prisma.$transaction(async (tx) => { + const storeItem = await this.ensureStoreItem(businessId, productId, tx); + const baseSortOrder = await tx.storeItemVariant.count({ + where: { storeItemId: storeItem.id }, + }); + const results: VariantWithRelations[] = []; + + for (const [index, item] of dto.items.entries()) { + const variant = await tx.storeItemVariant.create({ + data: { + businessId, + storeItemId: storeItem.id, + price: item.price ?? null, + stockQuantity: item.stockQuantity ?? null, + sortOrder: baseSortOrder + index, + }, + }); + + for (const selection of item.selections.filter((entry) => entry.optionId?.trim())) { + await tx.storeItemVariantSelection.create({ + data: { + variantId: variant.id, + variationId: BigInt(selection.variationId), + optionId: BigInt(selection.optionId), + }, + }); + } + + results.push( + await tx.storeItemVariant.findUniqueOrThrow({ + where: { id: variant.id }, + include: variantInclude, + }), + ); + } + + return results; + }); + + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + message: 'Store item variants created successfully', + items: created.map((item) => + this.serializeVariant(item, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }; + } + + async updateVariant( + businessIdRaw: string, + variantIdRaw: string, + dto: UpdateStoreItemVariantDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const variantId = BigInt(variantIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const existing = await this.prisma.storeItemVariant.findFirst({ + where: { id: variantId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Store item variant not found'); + } + + const price = dto.price ?? (existing.price === null ? null : Number(existing.price)); + const discountedPrice = + dto.discountedPrice === undefined + ? existing.compareAtPrice === null + ? null + : Number(existing.compareAtPrice) + : dto.discountedPrice; + + if (discountedPrice !== null && price !== null && discountedPrice >= price) { + throw new BadRequestException('Discounted price must be lower than the regular price'); + } + + const updated = await this.prisma.storeItemVariant.update({ + where: { id: variantId }, + data: { + ...(dto.price !== undefined ? { price: dto.price } : {}), + ...(dto.stockQuantity !== undefined ? { stockQuantity: dto.stockQuantity } : {}), + ...(dto.discountedPrice !== undefined + ? { compareAtPrice: dto.discountedPrice } + : {}), + ...(dto.isFestival !== undefined ? { isFestival: dto.isFestival } : {}), + }, + include: variantInclude, + }); + + const productId = updated.storeItem.productId; + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + message: 'Store item variant updated successfully', + item: this.serializeVariant(updated, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + }; + } + + async updateDiscounts( + businessIdRaw: string, + dto: BatchUpdateStoreItemDiscountsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(dto.productId); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const variants = await this.prisma.storeItemVariant.findMany({ + where: { businessId, storeItem: { productId } }, + }); + + const variantMap = new Map(variants.map((item) => [item.id.toString(), item])); + + for (const entry of dto.items) { + const variant = variantMap.get(entry.id); + if (!variant) { + throw new BadRequestException(`Variant "${entry.id}" not found for this product`); + } + + const price = variant.price === null ? null : Number(variant.price); + if ( + entry.discountedPrice !== null && + entry.discountedPrice !== undefined && + price !== null && + entry.discountedPrice >= price + ) { + throw new BadRequestException( + `Discounted price for "${entry.id}" must be lower than the regular price`, + ); + } + } + + await this.prisma.$transaction( + dto.items.map((entry) => + this.prisma.storeItemVariant.update({ + where: { id: BigInt(entry.id) }, + data: { + compareAtPrice: + entry.discountedPrice === undefined ? undefined : entry.discountedPrice, + }, + }), + ), + ); + + const refreshed = await this.getVariantsForProduct(businessId, productId); + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + message: 'Variant discounts updated successfully', + items: refreshed.map((item) => + this.serializeVariant(item, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }; + } + + async syncProductVariants( + businessIdRaw: string, + dto: SyncProductStoreItemVariantsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(dto.productId); + await this.assertPermission(businessId, actor.id, 'products.update'); + + if (!dto.items.length && !dto.removedIds.length) { + throw new BadRequestException('No variant changes to save'); + } + + await this.assertProductWithCategory(businessId, productId); + + const [categoryVariations, productValues, existingVariants] = + await this.loadVariantContext(businessId, productId); + + const variationMap = new Map( + categoryVariations.map((variation) => [variation.id.toString(), variation]), + ); + const allowedOptionIds = new Set( + productValues.map((item) => item.optionId.toString()), + ); + const variantMap = new Map( + existingVariants.map((variant) => [variant.id.toString(), variant]), + ); + + for (const removedId of dto.removedIds) { + if (!variantMap.has(removedId)) { + throw new BadRequestException(`Variant "${removedId}" not found for this product`); + } + } + + const survivingVariants = existingVariants.filter( + (variant) => !dto.removedIds.includes(variant.id.toString()), + ); + const existingKeys = new Map( + survivingVariants.map((variant) => [ + variant.id.toString(), + this.selectionKey(variant.selections.map((selection) => selection.optionId.toString())), + ]), + ); + const batchKeys = new Set(); + + for (const item of dto.items) { + const createDto: CreateStoreItemVariantDto = { + selections: item.selections, + price: item.price, + stockQuantity: item.stockQuantity, + }; + + if (item.id && !variantMap.has(item.id)) { + throw new BadRequestException(`Variant "${item.id}" not found for this product`); + } + + this.validateVariantItem( + createDto, + variationMap, + allowedOptionIds, + new Set( + [...existingKeys.entries()] + .filter(([id]) => id !== item.id) + .map(([, key]) => key), + ), + batchKeys, + ); + + const key = this.selectionKey( + item.selections.filter((selection) => selection.optionId?.trim()).map((s) => s.optionId), + ); + if (item.id) { + existingKeys.set(item.id, key); + } + } + + await this.prisma.$transaction(async (tx) => { + const storeItem = await this.ensureStoreItem(businessId, productId, tx); + const baseSortOrder = await tx.storeItemVariant.count({ + where: { storeItemId: storeItem.id }, + }); + + for (const removedId of dto.removedIds) { + await tx.storeItemVariant.delete({ where: { id: BigInt(removedId) } }); + } + + let createIndex = 0; + for (const item of dto.items) { + const activeSelections = item.selections.filter((selection) => selection.optionId?.trim()); + + if (item.id) { + const variantId = BigInt(item.id); + await tx.storeItemVariant.update({ + where: { id: variantId }, + data: { + price: item.price, + stockQuantity: item.stockQuantity, + }, + }); + await tx.storeItemVariantSelection.deleteMany({ where: { variantId } }); + for (const selection of activeSelections) { + await tx.storeItemVariantSelection.create({ + data: { + variantId, + variationId: BigInt(selection.variationId), + optionId: BigInt(selection.optionId), + }, + }); + } + continue; + } + + const variant = await tx.storeItemVariant.create({ + data: { + businessId, + storeItemId: storeItem.id, + price: item.price, + stockQuantity: item.stockQuantity, + sortOrder: baseSortOrder + createIndex, + }, + }); + createIndex += 1; + + for (const selection of activeSelections) { + await tx.storeItemVariantSelection.create({ + data: { + variantId: variant.id, + variationId: BigInt(selection.variationId), + optionId: BigInt(selection.optionId), + }, + }); + } + } + }); + + const refreshed = await this.getVariantsForProduct(businessId, productId); + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + message: 'Store item variants updated successfully', + items: refreshed.map((item) => + this.serializeVariant(item, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }; + } + + async updateFestivalRewards( + businessIdRaw: string, + dto: BatchUpdateFestivalRewardsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(dto.productId); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const variants = await this.prisma.storeItemVariant.findMany({ + where: { businessId, storeItem: { productId } }, + }); + + const variantMap = new Map(variants.map((item) => [item.id.toString(), item])); + + for (const entry of dto.items) { + if (!variantMap.has(entry.id)) { + throw new BadRequestException(`Variant "${entry.id}" not found for this product`); + } + if (entry.rewardPoints !== null && entry.rewardPoints !== undefined && entry.rewardPoints < 0) { + throw new BadRequestException('Reward points cannot be negative'); + } + } + + await this.prisma.$transaction( + dto.items.map((entry) => + this.prisma.storeItemVariant.update({ + where: { id: BigInt(entry.id) }, + data: { + rewardPoints: entry.rewardPoints ?? null, + isFestival: Boolean(entry.rewardPoints && entry.rewardPoints > 0), + }, + }), + ), + ); + + const refreshed = await this.getVariantsForProduct(businessId, productId); + const [galleryByProduct, stockByProduct] = await Promise.all([ + this.loadFirstGalleryUrls(businessId, [productId]), + this.loadStockTotals(businessId, [productId]), + ]); + + return { + message: 'Festival reward points updated successfully', + items: refreshed.map((item) => + this.serializeVariant(item, { + galleryUrl: galleryByProduct.get(productId.toString()) ?? null, + productTotalStock: stockByProduct.get(productId.toString()) ?? 0, + }), + ), + }; + } + + async removeVariant( + businessIdRaw: string, + variantIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const variantId = BigInt(variantIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const existing = await this.prisma.storeItemVariant.findFirst({ + where: { id: variantId, businessId }, + }); + + if (!existing) { + throw new NotFoundException('Store item variant not found'); + } + + await this.prisma.storeItemVariant.delete({ where: { id: variantId } }); + + return { message: 'Store item variant deleted successfully' }; + } + + async removeByProduct( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const result = await this.prisma.storeItem.deleteMany({ + where: { businessId, productId }, + }); + + if (result.count === 0) { + throw new NotFoundException('No store item found for this product'); + } + + return { message: 'Store item removed successfully' }; + } + + private async getVariantsForProduct(businessId: bigint, productId: bigint) { + return this.prisma.storeItemVariant.findMany({ + where: { businessId, storeItem: { productId } }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: variantInclude, + }); + } + + private async loadVariantContext(businessId: bigint, productId: bigint) { + const categoryId = await this.getProductCategoryId(businessId, productId); + if (!categoryId) { + throw new BadRequestException( + 'Assign a category to this product before managing store item variants', + ); + } + + return Promise.all([ + this.prisma.categoryVariation.findMany({ + where: { businessId, categoryId }, + include: { options: true }, + }), + this.prisma.productVariationValue.findMany({ where: { productId } }), + this.prisma.storeItemVariant.findMany({ + where: { businessId, storeItem: { productId } }, + include: { selections: true }, + }), + ]); + } + + private async assertProductWithCategory(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { id: productId, businessId }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + + const categoryId = await this.getProductCategoryId(businessId, productId); + if (!categoryId) { + throw new BadRequestException( + 'Assign a category to this product before creating store item variants', + ); + } + } + + private async ensureStoreItem( + businessId: bigint, + productId: bigint, + tx: Prisma.TransactionClient = this.prisma, + ) { + const existing = await tx.storeItem.findUnique({ + where: { businessId_productId: { businessId, productId } }, + }); + + if (existing) { + return existing; + } + + return tx.storeItem.create({ + data: { businessId, productId }, + }); + } + + private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) { + if (productIds.length === 0) { + return new Map(); + } + + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: { in: productIds }, + isFeatured: false, + }, + orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }], + include: { media: true }, + }); + + const map = new Map(); + for (const attachment of attachments) { + const key = attachment.entityId.toString(); + if (!map.has(key)) { + map.set(key, attachment.media.publicUrl); + } + } + + return map; + } + + private async loadStockTotals(businessId: bigint, productIds: bigint[]) { + if (productIds.length === 0) { + return new Map(); + } + + const storeItems = await this.prisma.storeItem.findMany({ + where: { businessId, productId: { in: productIds } }, + select: { id: true, productId: true }, + }); + + if (storeItems.length === 0) { + return new Map(); + } + + const rows = await this.prisma.storeItemVariant.groupBy({ + by: ['storeItemId'], + where: { + businessId, + storeItemId: { in: storeItems.map((item) => item.id) }, + }, + _sum: { stockQuantity: true }, + }); + + const productByStoreItem = new Map( + storeItems.map((item) => [item.id.toString(), item.productId.toString()]), + ); + + return new Map( + rows.map((row) => [ + productByStoreItem.get(row.storeItemId.toString()) ?? row.storeItemId.toString(), + row._sum.stockQuantity ?? 0, + ]), + ); + } + + private validateVariantItem( + item: CreateStoreItemVariantDto, + variationMap: Map, + allowedOptionIds: Set, + existingKeys: Set, + batchKeys: Set, + ) { + const seenVariationIds = new Set(); + const activeSelections = item.selections.filter((selection) => selection.optionId?.trim()); + + for (const selection of activeSelections) { + if (seenVariationIds.has(selection.variationId)) { + throw new BadRequestException( + `Duplicate variation "${selection.variationId}" in one variant`, + ); + } + seenVariationIds.add(selection.variationId); + + const variation = variationMap.get(selection.variationId); + if (!variation) { + throw new BadRequestException( + `Variation "${selection.variationId}" is not defined for this product category`, + ); + } + + const validOptionIds = new Set( + variation.options.map((option) => option.id.toString()), + ); + + if (!validOptionIds.has(selection.optionId)) { + throw new BadRequestException( + `Option "${selection.optionId}" does not belong to variation "${variation.name}"`, + ); + } + + if (allowedOptionIds.size > 0 && !allowedOptionIds.has(selection.optionId)) { + throw new BadRequestException( + `Option "${selection.optionId}" is not enabled for this product`, + ); + } + } + + const key = this.selectionKey(activeSelections.map((selection) => selection.optionId)); + if (existingKeys.has(key)) { + throw new BadRequestException('A variant with this variation combination already exists'); + } + if (batchKeys.has(key)) { + throw new BadRequestException('Duplicate variation combination in request'); + } + + existingKeys.add(key); + batchKeys.add(key); + + if (item.price !== undefined && item.price < 0) { + throw new BadRequestException('Price cannot be negative'); + } + + if (item.stockQuantity !== undefined && item.stockQuantity < 0) { + throw new BadRequestException('Stock cannot be negative'); + } + } + + private selectionKey(optionIds: string[]) { + return [...optionIds].sort().join(':'); + } + + private serializeVariant( + variant: VariantWithRelations, + extras: { galleryUrl: string | null; productTotalStock: number }, + ) { + const product = variant.storeItem.product; + const content = this.asRecord(product.content); + const selections = variant.selections.map((selection) => ({ + variationId: selection.variation.id.toString(), + variationName: selection.variation.name, + optionId: selection.option.id.toString(), + value: selection.option.label, + })); + + const price = variant.price === null ? null : Number(variant.price); + const discountedPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; + const productImage = thumbnailUrl ?? extras.galleryUrl ?? null; + + return { + id: variant.id.toString(), + storeItemId: variant.storeItemId.toString(), + productId: product.id.toString(), + productTitle: product.title, + productNameFa: (content.nameFa as string | null | undefined) ?? '', + productImage, + productTotalStock: extras.productTotalStock, + selections, + label: selections.map((item) => item.value).join(' · ') || product.title, + price, + discountedPrice: + discountedPrice !== null && price !== null && discountedPrice < price + ? discountedPrice + : null, + stockQuantity: variant.stockQuantity, + rewardPoints: variant.rewardPoints, + isFestival: variant.isFestival, + sortOrder: variant.sortOrder, + createdAt: variant.createdAt, + }; + } + + private asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async getProductCategoryId(businessId: bigint, productId: bigint) { + const assignment = await this.prisma.categoryAssignment.findFirst({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: productId, + }, + }); + + return assignment?.categoryId ?? null; + } + + private async buildPublicVariantWhere( + businessId: bigint, + query: ListPublicStoreItemsDto, + ): Promise { + let productIds: bigint[] | undefined; + + if (query.productId) { + productIds = [BigInt(query.productId)]; + } else if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.product, + }, + select: { entityId: true }, + }); + + productIds = assignments.map((item) => item.entityId); + if (productIds.length === 0) { + return { id: { in: [] } }; + } + } + + const name = query.name?.trim(); + + return { + businessId, + isActive: true, + storeItem: { + isActive: true, + product: { + status: ContentStatus.published, + ...(query.brandId ? { brandId: BigInt(query.brandId) } : {}), + ...(productIds ? { id: { in: productIds } } : {}), + ...(name + ? { + OR: [ + { title: { contains: name, mode: 'insensitive' } }, + { + content: { + path: ['nameFa'], + string_contains: name, + }, + }, + ], + } + : {}), + }, + }, + ...(query.inStock ? { stockQuantity: { gt: 0 } } : {}), + ...(query.isFestival ? { isFestival: true } : {}), + ...(query.minPrice !== undefined || query.maxPrice !== undefined + ? { + price: { + ...(query.minPrice !== undefined ? { gte: query.minPrice } : {}), + ...(query.maxPrice !== undefined ? { lte: query.maxPrice } : {}), + }, + } + : {}), + }; + } + + private async assertPublishedProductExists(businessId: bigint, productId: bigint) { + const product = await this.prisma.product.findFirst({ + where: { + id: productId, + businessId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('Product not found'); + } + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/store/store-specials.controller.ts b/src/store/store-specials.controller.ts new file mode 100644 index 0000000..b8bbd7d --- /dev/null +++ b/src/store/store-specials.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateStoreSpecialDto, + ListStoreSpecialsDto, + UpdateStoreSpecialDto, +} from './dto/store-specials.dto'; +import { StoreSpecialsService } from './store-specials.service'; + +@Controller('tenants/:host/store-specials') +export class PublicStoreSpecialsController { + constructor(private readonly service: StoreSpecialsService) {} + + @Get() + list(@Param('host') host: string) { + return this.service.listPublic(host); + } +} + +@Controller('businesses/:businessId/store-specials') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class StoreSpecialsController { + constructor(private readonly service: StoreSpecialsService) {} + + @Get() + @RequireBusinessPermission('products.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListStoreSpecialsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':specialId') + @RequireBusinessPermission('products.read') + getOne( + @Param('businessId') businessId: string, + @Param('specialId') specialId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, specialId, user); + } + + @Post() + @RequireBusinessPermission('products.update') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateStoreSpecialDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':specialId') + @RequireBusinessPermission('products.update') + update( + @Param('businessId') businessId: string, + @Param('specialId') specialId: string, + @Body() dto: UpdateStoreSpecialDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, specialId, dto, user); + } + + @Delete(':specialId') + @RequireBusinessPermission('products.update') + remove( + @Param('businessId') businessId: string, + @Param('specialId') specialId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, specialId, user); + } +} diff --git a/src/store/store-specials.service.ts b/src/store/store-specials.service.ts new file mode 100644 index 0000000..d053f24 --- /dev/null +++ b/src/store/store-specials.service.ts @@ -0,0 +1,425 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateStoreSpecialDto, + ListStoreSpecialsDto, + UpdateStoreSpecialDto, +} from './dto/store-specials.dto'; + +const storeItemInclude = { + product: { + include: { + featuredMedia: true, + }, + }, + variants: { + orderBy: [{ sortOrder: 'asc' as const }, { createdAt: 'asc' as const }], + include: { + selections: { + include: { + variation: true, + option: true, + }, + }, + }, + }, +} satisfies Prisma.StoreItemInclude; + +const specialInclude = { + items: { + orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }], + include: { + storeItem: { + include: storeItemInclude, + }, + }, + }, +} satisfies Prisma.StoreSpecialInclude; + +type SpecialWithItems = Prisma.StoreSpecialGetPayload<{ + include: typeof specialInclude; +}>; + +@Injectable() +export class StoreSpecialsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const specials = await this.prisma.storeSpecial.findMany({ + where: { businessId: business.id, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + include: specialInclude, + }); + + const galleryByProduct = await this.loadFirstGalleryUrls( + business.id, + this.collectProductIds(specials), + ); + + return { + items: specials.map((special) => + this.serializeSpecial(special, true, galleryByProduct), + ), + }; + } + + async list( + businessIdRaw: string, + query: ListStoreSpecialsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.StoreSpecialWhereInput = { + businessId, + ...(query.isActive !== undefined ? { isActive: query.isActive } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.storeSpecial.findMany({ + where, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + skip, + take: pageSize, + include: specialInclude, + }), + this.prisma.storeSpecial.count({ where }), + ]); + + const galleryByProduct = await this.loadFirstGalleryUrls( + businessId, + this.collectProductIds(items), + ); + + return { + items: items.map((special) => + this.serializeSpecial(special, false, galleryByProduct), + ), + total, + page, + pageSize, + }; + } + + async getOne( + businessIdRaw: string, + specialIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const specialId = BigInt(specialIdRaw); + await this.assertPermission(businessId, actor.id, 'products.read'); + + const special = await this.findSpecialOrThrow(businessId, specialId); + const galleryByProduct = await this.loadFirstGalleryUrls( + businessId, + this.collectProductIds([special]), + ); + return { special: this.serializeSpecial(special, false, galleryByProduct) }; + } + + async create( + businessIdRaw: string, + dto: CreateStoreSpecialDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + const storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds ?? []); + if (storeItemIds.length > 0) { + await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); + } + + const created = await this.prisma.$transaction(async (tx) => { + const special = await tx.storeSpecial.create({ + data: { + businessId, + title: dto.title.trim(), + sortOrder: dto.sortOrder ?? 0, + isActive: dto.isActive ?? true, + }, + }); + + await this.replaceItems(tx, special.id, storeItemIds); + return tx.storeSpecial.findUniqueOrThrow({ + where: { id: special.id }, + include: specialInclude, + }); + }); + + const galleryByProduct = await this.loadFirstGalleryUrls( + businessId, + this.collectProductIds([created]), + ); + + return { + message: 'Store special created successfully', + special: this.serializeSpecial(created, false, galleryByProduct), + }; + } + + async update( + businessIdRaw: string, + specialIdRaw: string, + dto: UpdateStoreSpecialDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const specialId = BigInt(specialIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + await this.findSpecialOrThrow(businessId, specialId); + + let storeItemIds: bigint[] | undefined; + if (dto.storeItemIds !== undefined) { + storeItemIds = this.parseUniqueStoreItemIds(dto.storeItemIds); + await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); + } + + const updated = await this.prisma.$transaction(async (tx) => { + await tx.storeSpecial.update({ + where: { id: specialId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), + }, + }); + + if (storeItemIds !== undefined) { + await this.replaceItems(tx, specialId, storeItemIds); + } + + return tx.storeSpecial.findUniqueOrThrow({ + where: { id: specialId }, + include: specialInclude, + }); + }); + + const galleryByProduct = await this.loadFirstGalleryUrls( + businessId, + this.collectProductIds([updated]), + ); + + return { + message: 'Store special updated successfully', + special: this.serializeSpecial(updated, false, galleryByProduct), + }; + } + + async remove( + businessIdRaw: string, + specialIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const specialId = BigInt(specialIdRaw); + await this.assertPermission(businessId, actor.id, 'products.update'); + + await this.findSpecialOrThrow(businessId, specialId); + await this.prisma.storeSpecial.delete({ where: { id: specialId } }); + + return { message: 'Store special deleted successfully' }; + } + + private async findSpecialOrThrow(businessId: bigint, specialId: bigint) { + const special = await this.prisma.storeSpecial.findFirst({ + where: { id: specialId, businessId }, + include: specialInclude, + }); + + if (!special) { + throw new NotFoundException('Store special not found'); + } + + return special; + } + + private parseUniqueStoreItemIds(ids: string[]) { + const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; + return unique.map((id) => BigInt(id)); + } + + private async assertStoreItemsBelongToBusiness( + businessId: bigint, + storeItemIds: bigint[], + ) { + const found = await this.prisma.storeItem.findMany({ + where: { businessId, id: { in: storeItemIds } }, + select: { id: true }, + }); + + if (found.length !== storeItemIds.length) { + throw new BadRequestException('One or more store items were not found for this business'); + } + } + + private async replaceItems( + tx: Prisma.TransactionClient, + specialId: bigint, + storeItemIds: bigint[], + ) { + await tx.storeSpecialItem.deleteMany({ where: { specialId } }); + + await tx.storeSpecialItem.createMany({ + data: storeItemIds.map((storeItemId, index) => ({ + specialId, + storeItemId, + sortOrder: index, + })), + }); + } + + private collectProductIds(specials: SpecialWithItems[]) { + const productIds = new Set(); + for (const special of specials) { + for (const entry of special.items) { + productIds.add(entry.storeItem.productId); + } + } + return [...productIds]; + } + + private async loadFirstGalleryUrls(businessId: bigint, productIds: bigint[]) { + if (productIds.length === 0) { + return new Map(); + } + + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + entityId: { in: productIds }, + isFeatured: false, + }, + orderBy: [{ entityId: 'asc' }, { sortOrder: 'asc' }], + include: { media: true }, + }); + + const map = new Map(); + for (const attachment of attachments) { + const key = attachment.entityId.toString(); + if (!map.has(key)) { + map.set(key, attachment.media.publicUrl); + } + } + + return map; + } + + private serializeSpecial( + special: SpecialWithItems, + publicView: boolean, + galleryByProduct: Map, + ) { + const items = special.items + .filter((entry) => !publicView || entry.storeItem.isActive) + .map((entry) => ({ + ...this.serializeStoreItem(entry.storeItem, galleryByProduct), + sortOrder: entry.sortOrder, + })); + + return { + id: special.id.toString(), + title: special.title, + sortOrder: special.sortOrder, + isActive: special.isActive, + createdAt: special.createdAt, + updatedAt: special.updatedAt, + items, + }; + } + + private serializeStoreItem( + storeItem: Prisma.StoreItemGetPayload<{ include: typeof storeItemInclude }>, + galleryByProduct: Map, + ) { + const product = storeItem.product; + const content = this.asRecord(product.content); + const thumbnailUrl = product.featuredMedia?.publicUrl ?? null; + const productImage = + thumbnailUrl ?? galleryByProduct.get(product.id.toString()) ?? null; + + return { + id: storeItem.id.toString(), + productId: product.id.toString(), + productTitle: product.title, + productNameFa: (content.nameFa as string | null | undefined) ?? '', + productImage, + isActive: storeItem.isActive, + sortOrder: storeItem.sortOrder, + variants: storeItem.variants.map((variant) => { + const selections = variant.selections.map((selection) => ({ + variationId: selection.variation.id.toString(), + variationName: selection.variation.name, + optionId: selection.option.id.toString(), + value: selection.option.label, + })); + + const price = variant.price === null ? null : Number(variant.price); + const discountedPrice = + variant.compareAtPrice === null ? null : Number(variant.compareAtPrice); + + return { + id: variant.id.toString(), + label: selections.map((item) => item.value).join(' · ') || product.title, + selections, + price, + discountedPrice: + discountedPrice !== null && price !== null && discountedPrice < price + ? discountedPrice + : null, + stockQuantity: variant.stockQuantity, + rewardPoints: variant.rewardPoints, + isFestival: variant.isFestival, + sortOrder: variant.sortOrder, + }; + }), + }; + } + + private asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/store/store.module.ts b/src/store/store.module.ts new file mode 100644 index 0000000..8b3045f --- /dev/null +++ b/src/store/store.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + PublicStoreSpecialsController, + StoreSpecialsController, +} from './store-specials.controller'; +import { StoreSpecialsService } from './store-specials.service'; +import { StoreItemsController, PublicStoreItemsController } from './store-items.controller'; +import { StoreItemsService } from './store-items.service'; + +@Module({ + imports: [AuthModule, TenantModule], + controllers: [ + StoreItemsController, + PublicStoreItemsController, + PublicStoreSpecialsController, + StoreSpecialsController, + ], + providers: [StoreItemsService, StoreSpecialsService], + exports: [StoreItemsService, StoreSpecialsService], +}) +export class StoreModule {} diff --git a/src/tenant/tenant.controller.ts b/src/tenant/tenant.controller.ts new file mode 100644 index 0000000..bd4c01a --- /dev/null +++ b/src/tenant/tenant.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { TenantService } from './tenant.service'; + +@Controller('tenants') +export class TenantController { + constructor(private readonly tenant: TenantService) {} + + /** Resolve business from website/dashboard domain (e.g. sanihome.ir). */ + @Get(':host') + resolve(@Param('host') host: string) { + return this.tenant.resolvePublicBusinessByDomain(host); + } +} diff --git a/src/tenant/tenant.module.ts b/src/tenant/tenant.module.ts new file mode 100644 index 0000000..094a6ee --- /dev/null +++ b/src/tenant/tenant.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TenantController } from './tenant.controller'; +import { TenantService } from './tenant.service'; + +@Module({ + controllers: [TenantController], + providers: [TenantService], + exports: [TenantService], +}) +export class TenantModule {} diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts new file mode 100644 index 0000000..d2ec3a7 --- /dev/null +++ b/src/tenant/tenant.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { normalizeBusinessSettings } from '../business-settings/business-settings.util'; + +const DASHBOARD_SUBDOMAIN_PREFIXES = ['customer.', 'business.'] as const; + +@Injectable() +export class TenantService { + constructor(private readonly prisma: PrismaService) {} + + private normalizeTenantHost(host: string) { + const normalizedHost = host.toLowerCase().trim(); + + for (const prefix of DASHBOARD_SUBDOMAIN_PREFIXES) { + if (normalizedHost.startsWith(prefix)) { + return normalizedHost.slice(prefix.length); + } + } + + return normalizedHost; + } + + async resolveBusinessByDomain(host: string) { + const normalizedHost = this.normalizeTenantHost(host); + + const domain = await this.prisma.domain.findUnique({ + where: { host: normalizedHost }, + include: { business: true }, + }); + + if (!domain || !domain.business.isActive) { + throw new NotFoundException(`No business found for domain: ${host}`); + } + + return domain.business; + } + + async resolvePublicBusinessByDomain(host: string) { + const business = await this.resolveBusinessByDomain(host); + const normalizedHost = this.normalizeTenantHost(host); + const settings = normalizeBusinessSettings(business.settings); + + const media = await this.prisma.business.findUnique({ + where: { id: business.id }, + select: { + logoMedia: { select: { publicUrl: true } }, + faviconMedia: { select: { publicUrl: true } }, + }, + }); + + return { + id: business.id, + name: business.name, + nameFa: business.nameFa, + slug: business.slug, + domain: normalizedHost, + primaryColor: settings.branding.primaryColor, + logoUrl: media?.logoMedia?.publicUrl ?? null, + faviconUrl: + media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null, + }; + } +} diff --git a/src/transactions/dto/transaction.dto.ts b/src/transactions/dto/transaction.dto.ts new file mode 100644 index 0000000..fc0297b --- /dev/null +++ b/src/transactions/dto/transaction.dto.ts @@ -0,0 +1,37 @@ +import { + IsEnum, + IsOptional, + IsString, + MinLength, + ValidateIf, +} from 'class-validator'; +import { TransactionType } from '@prisma/client'; + +export class CreateTransactionPaymentDto { + @IsEnum(TransactionType) + type!: TransactionType; + + @ValidateIf((dto) => dto.type === TransactionType.pos) + @IsString() + @MinLength(1) + posType?: string; + + @ValidateIf((dto) => dto.type === TransactionType.transfer) + @IsString() + @MinLength(1) + transferAccount?: string; + + @ValidateIf((dto) => dto.type === TransactionType.transfer) + @IsString() + @MinLength(1) + transferRefNumber?: string; + + @ValidateIf((dto) => dto.type === TransactionType.e_payment_gate) + @IsString() + @MinLength(1) + gatewayType?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/users/dto/admin-reset-password.dto.ts b/src/users/dto/admin-reset-password.dto.ts new file mode 100644 index 0000000..c673343 --- /dev/null +++ b/src/users/dto/admin-reset-password.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class AdminResetPasswordDto { + @IsString() + @MinLength(8) + newPassword!: string; +} diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..b9521e7 --- /dev/null +++ b/src/users/dto/create-user.dto.ts @@ -0,0 +1,38 @@ +import { Type } from 'class-transformer'; +import { + IsEmail, + IsInt, + IsOptional, + IsString, + Matches, + MinLength, +} from 'class-validator'; + +export class CreateUserDto { + @IsString() + @Matches(/^\+[1-9]\d{6,14}$/, { + message: 'cellNumber must be in E.164 format (e.g. +989121234567)', + }) + cellNumber!: string; + + @IsOptional() + @IsString() + @MinLength(8) + password?: string; + + @IsString() + @MinLength(2) + firstName!: string; + + @IsString() + @MinLength(2) + lastName!: string; + + @IsOptional() + @IsEmail() + email?: string; + + @Type(() => Number) + @IsInt() + businessId!: number; +} diff --git a/src/users/dto/list-users.dto.ts b/src/users/dto/list-users.dto.ts new file mode 100644 index 0000000..30499dd --- /dev/null +++ b/src/users/dto/list-users.dto.ts @@ -0,0 +1,39 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListUsersDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + pageSize?: number; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + cellNumber?: string; + + @IsOptional() + @IsString() + role?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + businessId?: number; + + @IsOptional() + @IsIn(['staff', 'all']) + membership?: 'staff' | 'all'; +} diff --git a/src/users/dto/search-users.dto.ts b/src/users/dto/search-users.dto.ts new file mode 100644 index 0000000..de1b4e8 --- /dev/null +++ b/src/users/dto/search-users.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SearchUsersDto { + @IsString() + @MinLength(2, { message: 'q must be at least 2 characters' }) + q!: string; + + @IsOptional() + @Type(() => Number) + limit?: number = 20; +} diff --git a/src/users/dto/send-user-message.dto.ts b/src/users/dto/send-user-message.dto.ts new file mode 100644 index 0000000..8da7629 --- /dev/null +++ b/src/users/dto/send-user-message.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class SendUserMessageDto { + @IsString() + @MinLength(1) + message!: string; +} diff --git a/src/users/dto/update-user-role.dto.ts b/src/users/dto/update-user-role.dto.ts new file mode 100644 index 0000000..77461f7 --- /dev/null +++ b/src/users/dto/update-user-role.dto.ts @@ -0,0 +1,8 @@ +import { IsIn, IsString } from 'class-validator'; +import { ASSIGNABLE_GLOBAL_ROLES } from '../../auth/auth.types'; + +export class UpdateUserRoleDto { + @IsString() + @IsIn([...ASSIGNABLE_GLOBAL_ROLES]) + roleSlug!: (typeof ASSIGNABLE_GLOBAL_ROLES)[number]; +} diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..23a7de0 --- /dev/null +++ b/src/users/dto/update-user.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + @MinLength(1) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(1) + lastName?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + @MinLength(10) + cellNumber?: string; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..81dd7d3 --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AdminResetPasswordDto } from './dto/admin-reset-password.dto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { ListUsersDto } from './dto/list-users.dto'; +import { SearchUsersDto } from './dto/search-users.dto'; +import { SendUserMessageDto } from './dto/send-user-message.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; +import { UsersService } from './users.service'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get('search') + @UseGuards(JwtAuthGuard) + search(@Query() query: SearchUsersDto, @CurrentUser() user: AuthUser) { + return this.usersService.search(query, user); + } + + @Get() + @UseGuards(JwtAuthGuard) + list(@Query() query: ListUsersDto, @CurrentUser() user: AuthUser) { + return this.usersService.list(query, user); + } + + @Post() + @UseGuards(JwtAuthGuard) + create(@Body() dto: CreateUserDto, @CurrentUser() user: AuthUser) { + return this.usersService.create(dto, user); + } + + @Patch(':userId/role') + @UseGuards(JwtAuthGuard) + updateRole( + @Param('userId') userId: string, + @Body() dto: UpdateUserRoleDto, + @CurrentUser() user: AuthUser, + ) { + return this.usersService.updateRole(userId, dto, user); + } + + @Patch(':userId') + @UseGuards(JwtAuthGuard) + update( + @Param('userId') userId: string, + @Body() dto: UpdateUserDto, + @CurrentUser() user: AuthUser, + ) { + return this.usersService.update(userId, dto, user); + } + + @Post(':userId/reset-password') + @UseGuards(JwtAuthGuard) + resetPassword( + @Param('userId') userId: string, + @Body() dto: AdminResetPasswordDto, + @CurrentUser() user: AuthUser, + ) { + return this.usersService.resetPassword(userId, dto, user); + } + + @Post(':userId/send-message') + @UseGuards(JwtAuthGuard) + sendMessage( + @Param('userId') userId: string, + @Body() dto: SendUserMessageDto, + @CurrentUser() user: AuthUser, + ) { + return this.usersService.sendMessage(userId, dto, user); + } + + @Delete(':userId') + @UseGuards(JwtAuthGuard) + remove(@Param('userId') userId: string, @CurrentUser() user: AuthUser) { + return this.usersService.remove(userId, user); + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..0e7e8a8 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [AuthModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..91094f8 --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,502 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { AuthUser, ASSIGNABLE_GLOBAL_ROLES } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { SmsService } from '../auth/sms.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AdminResetPasswordDto } from './dto/admin-reset-password.dto'; +import { CreateUserDto } from './dto/create-user.dto'; +import { ListUsersDto } from './dto/list-users.dto'; +import { SearchUsersDto } from './dto/search-users.dto'; +import { SendUserMessageDto } from './dto/send-user-message.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserRoleDto } from './dto/update-user-role.dto'; + +type UserListRow = { + id: bigint; + firstName: string | null; + lastName: string | null; + cellNumber: string; + roles: string | null; + roleSlug: string | null; + businesses: string | null; + businessMemberId: bigint | null; + isBusinessOwner: boolean | null; + teamRole: string | null; + createdAt: Date; + isActive: boolean; +}; + +@Injectable() +export class UsersService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly sms: SmsService, + ) {} + + private async assertSuperAdmin(actor: AuthUser) { + if (!(await this.permissions.isSuperAdmin(actor.id))) { + throw new ForbiddenException('Super admin access required'); + } + } + + async updateRole(userIdRaw: string, dto: UpdateUserRoleDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const userId = BigInt(userIdRaw); + if (userId === actor.id && dto.roleSlug !== 'super_admin') { + throw new BadRequestException('You cannot change your own role'); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || !user.isActive) { + throw new NotFoundException('User not found'); + } + + const targetRole = await this.prisma.role.findUnique({ + where: { slug: dto.roleSlug }, + }); + if (!targetRole) { + throw new BadRequestException('Invalid role'); + } + + const replaceableRoles = await this.prisma.role.findMany({ + where: { slug: { in: [...ASSIGNABLE_GLOBAL_ROLES] } }, + select: { id: true }, + }); + const replaceableRoleIds = replaceableRoles.map((role) => role.id); + + await this.prisma.$transaction(async (tx) => { + await tx.userRole.deleteMany({ + where: { + userId, + roleId: { in: replaceableRoleIds }, + }, + }); + + await tx.userRole.create({ + data: { + userId, + roleId: targetRole.id, + }, + }); + }); + + const userRoles = await this.prisma.userRole.findMany({ + where: { userId }, + include: { role: true }, + }); + + return { + id: userId, + role: dto.roleSlug, + roles: userRoles.map((entry) => entry.role.slug), + }; + } + + async list(query: ListUsersDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 24; + const skip = (page - 1) * pageSize; + + const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null; + const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null; + const roleSlug = query.role?.trim() || null; + const businessId = query.businessId ?? null; + const membership = query.membership ?? null; + + const where = Prisma.sql` + WHERE 1=1 + ${nameLike ? Prisma.sql` + AND ( + u.first_name ILIKE ${nameLike} + OR u.last_name ILIKE ${nameLike} + OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike} + ) + ` : Prisma.empty} + ${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty} + ${roleSlug ? Prisma.sql` + AND EXISTS ( + SELECT 1 + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = u.id AND r.slug = ${roleSlug} + ) + ` : Prisma.empty} + ${businessId && membership === 'staff' ? Prisma.sql` + AND EXISTS ( + SELECT 1 + FROM business_users bu + WHERE bu.user_id = u.id AND bu.business_id = ${businessId} + ) + ` : Prisma.empty} + ${businessId && membership === 'all' ? Prisma.sql` + AND EXISTS ( + SELECT 1 + FROM ( + SELECT user_id FROM business_users WHERE business_id = ${businessId} + UNION + SELECT user_id FROM business_customers WHERE business_id = ${businessId} + ) ub + WHERE ub.user_id = u.id + ) + ` : Prisma.empty} + `; + + const [items, totalRow] = await Promise.all([ + this.prisma.$queryRaw(Prisma.sql` + SELECT + u.id AS "id", + u.first_name AS "firstName", + u.last_name AS "lastName", + u.cell_number AS "cellNumber", + u.created_at AS "createdAt", + u.is_active AS "isActive", + roles.roles AS "roles", + global_role.slug AS "roleSlug", + biz.businesses AS "businesses", + biz_member.member_id AS "businessMemberId", + biz_member.is_owner AS "isBusinessOwner", + biz_member.team_slug AS "teamRole" + FROM users u + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT r.name, ', ' ORDER BY r.name) AS roles + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = u.id + ) roles ON TRUE + LEFT JOIN LATERAL ( + SELECT r.slug + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = u.id + AND r.slug IN (${Prisma.join([...ASSIGNABLE_GLOBAL_ROLES])}) + ORDER BY r.name + LIMIT 1 + ) global_role ON TRUE + LEFT JOIN LATERAL ( + SELECT string_agg(DISTINCT b.name, ', ' ORDER BY b.name) AS businesses + FROM ( + SELECT business_id FROM business_users WHERE user_id = u.id + UNION + SELECT business_id FROM business_customers WHERE user_id = u.id + ) ub + JOIN businesses b ON b.id = ub.business_id + ) biz ON TRUE + ${businessId ? Prisma.sql` + LEFT JOIN LATERAL ( + SELECT bu.id AS member_id, bu.is_owner, r.slug AS team_slug + FROM business_users bu + LEFT JOIN roles r ON r.id = bu.role_id + WHERE bu.user_id = u.id AND bu.business_id = ${businessId} + LIMIT 1 + ) biz_member ON TRUE + ` : Prisma.sql` + LEFT JOIN LATERAL ( + SELECT NULL::bigint AS member_id, NULL::boolean AS is_owner, NULL::text AS team_slug + ) biz_member ON TRUE + `} + ${where} + ORDER BY u.created_at DESC + LIMIT ${pageSize} OFFSET ${skip} + `), + this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql` + SELECT COUNT(*)::int AS "total" + FROM users u + ${where} + `), + ]); + + return { + items, + total: totalRow[0]?.total ?? 0, + page, + pageSize, + }; + } + + async create(dto: CreateUserDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const businessId = BigInt(dto.businessId); + const business = await this.prisma.business.findUnique({ + where: { id: businessId }, + }); + + if (!business?.isActive) { + throw new NotFoundException('Business not found'); + } + + const customerRole = await this.prisma.role.findUnique({ + where: { slug: 'customer' }, + }); + + if (!customerRole) { + throw new Error('Customer role is missing. Run database migrations first.'); + } + + const existingUser = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + include: { + businessCustomers: { where: { businessId } }, + }, + }); + + if (existingUser?.businessCustomers.length) { + throw new ConflictException('User is already a customer of this business'); + } + + if (existingUser) { + const staffMembership = await this.prisma.businessUser.findUnique({ + where: { + businessId_userId: { businessId, userId: existingUser.id }, + }, + }); + if (staffMembership) { + throw new ConflictException('User is already staff of this business'); + } + } + + if (!existingUser && !dto.password) { + throw new BadRequestException('password is required for new users'); + } + + const passwordHash = existingUser + ? existingUser.passwordHash + : await bcrypt.hash(dto.password!, 10); + + const user = await this.prisma.$transaction(async (tx) => { + const account = + existingUser ?? + (await tx.user.create({ + data: { + cellNumber: dto.cellNumber, + passwordHash, + email: dto.email, + firstName: dto.firstName, + lastName: dto.lastName, + cellVerifiedAt: new Date(), + }, + })); + + if (existingUser && !existingUser.cellVerifiedAt) { + await tx.user.update({ + where: { id: account.id }, + data: { cellVerifiedAt: new Date() }, + }); + } + + await tx.businessCustomer.create({ + data: { + businessId, + userId: account.id, + }, + }); + + const hasCustomerRole = await tx.userRole.findUnique({ + where: { + userId_roleId: { + userId: account.id, + roleId: customerRole.id, + }, + }, + }); + + if (!hasCustomerRole) { + await tx.userRole.create({ + data: { + userId: account.id, + roleId: customerRole.id, + }, + }); + } + + return account; + }); + + const verifiedAt = user.cellVerifiedAt ?? new Date(); + + return { + id: user.id, + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + cellVerifiedAt: verifiedAt, + isVerified: verifiedAt !== null, + businessId, + role: 'customer', + }; + } + + async search(query: SearchUsersDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const q = query.q.trim(); + const limit = Math.min(Math.max(query.limit ?? 20, 1), 50); + const like = `%${q}%`; + + const items = await this.prisma.$queryRaw< + { + id: bigint; + cellNumber: string; + firstName: string | null; + lastName: string | null; + email: string | null; + }[] + >(Prisma.sql` + SELECT + u.id AS "id", + u.cell_number AS "cellNumber", + u.first_name AS "firstName", + u.last_name AS "lastName", + u.email AS "email" + FROM users u + WHERE u.is_active = TRUE + AND ( + u.cell_number ILIKE ${like} + OR u.first_name ILIKE ${like} + OR u.last_name ILIKE ${like} + OR u.email ILIKE ${like} + OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like} + ) + ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST + LIMIT ${limit} + `); + + return { + items: items.map((user) => ({ + id: user.id, + cellNumber: user.cellNumber, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + label: this.formatLabel(user), + })), + }; + } + + async update(userIdRaw: string, dto: UpdateUserDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const userId = BigInt(userIdRaw); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + if (dto.cellNumber && dto.cellNumber !== user.cellNumber) { + const existing = await this.prisma.user.findUnique({ + where: { cellNumber: dto.cellNumber }, + }); + if (existing && existing.id !== userId) { + throw new BadRequestException('Cell number is already in use'); + } + } + + const updated = await this.prisma.user.update({ + where: { id: userId }, + data: { + firstName: dto.firstName?.trim(), + lastName: dto.lastName?.trim(), + email: dto.email !== undefined ? dto.email.trim() || null : undefined, + cellNumber: dto.cellNumber?.trim(), + }, + }); + + return { + id: updated.id, + firstName: updated.firstName, + lastName: updated.lastName, + email: updated.email, + cellNumber: updated.cellNumber, + }; + } + + async resetPassword(userIdRaw: string, dto: AdminResetPasswordDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const userId = BigInt(userIdRaw); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const passwordHash = await bcrypt.hash(dto.newPassword, 10); + await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash }, + }); + + return { message: 'Password reset successfully' }; + } + + async remove(userIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const userId = BigInt(userIdRaw); + if (userId === actor.id) { + throw new BadRequestException('You cannot remove your own account'); + } + + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + await this.prisma.user.update({ + where: { id: userId }, + data: { isActive: false }, + }); + + return { message: 'User removed' }; + } + + async sendMessage(userIdRaw: string, dto: SendUserMessageDto, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const userId = BigInt(userIdRaw); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!this.sms.isEnabled()) { + return { + enabled: false, + message: 'SMS is disabled. Message was not sent.', + }; + } + + try { + await this.sms.sendMessage(user.cellNumber, dto.message.trim()); + } catch { + throw new ServiceUnavailableException('SMS provider is not configured yet'); + } + + return { + enabled: true, + message: 'Message sent successfully', + }; + } + + private formatLabel(user: { + firstName: string | null; + lastName: string | null; + cellNumber: string; + }): string { + const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + return name ? `${name} (${user.cellNumber})` : user.cellNumber; + } +} diff --git a/src/website/dto/website-brand-groups.dto.ts b/src/website/dto/website-brand-groups.dto.ts new file mode 100644 index 0000000..6d4d335 --- /dev/null +++ b/src/website/dto/website-brand-groups.dto.ts @@ -0,0 +1,71 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateWebsiteBrandGroupDto { + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + brandIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateWebsiteBrandGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + brandIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ListWebsiteBrandGroupsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/website/dto/website-category-groups.dto.ts b/src/website/dto/website-category-groups.dto.ts new file mode 100644 index 0000000..341e099 --- /dev/null +++ b/src/website/dto/website-category-groups.dto.ts @@ -0,0 +1,71 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, +} from 'class-validator'; + +export class CreateWebsiteCategoryGroupDto { + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + categoryIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateWebsiteCategoryGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + categoryIds?: string[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ListWebsiteCategoryGroupsDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/website/dto/website-sliders.dto.ts b/src/website/dto/website-sliders.dto.ts new file mode 100644 index 0000000..c4d8176 --- /dev/null +++ b/src/website/dto/website-sliders.dto.ts @@ -0,0 +1,95 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + IsUrl, + MaxLength, + Min, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class WebsiteSliderSlideInputDto { + @IsString() + imageMediaId!: string; + + @IsOptional() + @IsString() + @MaxLength(255) + title?: string; + + @IsOptional() + @IsString() + @MaxLength(2048) + @IsUrl({ require_protocol: true }, { message: 'linkUrl must be a valid URL' }) + linkUrl?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateWebsiteSliderDto { + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WebsiteSliderSlideInputDto) + slides?: WebsiteSliderSlideInputDto[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateWebsiteSliderDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + title?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WebsiteSliderSlideInputDto) + slides?: WebsiteSliderSlideInputDto[]; + + @IsOptional() + @IsInt() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ListWebsiteSlidersDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/website/website-brand-groups.controller.ts b/src/website/website-brand-groups.controller.ts new file mode 100644 index 0000000..c16fcd7 --- /dev/null +++ b/src/website/website-brand-groups.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateWebsiteBrandGroupDto, + ListWebsiteBrandGroupsDto, + UpdateWebsiteBrandGroupDto, +} from './dto/website-brand-groups.dto'; +import { WebsiteBrandGroupsService } from './website-brand-groups.service'; + +@Controller('tenants/:host/website/brand-groups') +export class PublicWebsiteBrandGroupsController { + constructor(private readonly service: WebsiteBrandGroupsService) {} + + @Get() + list(@Param('host') host: string) { + return this.service.listPublic(host); + } +} + +@Controller('businesses/:businessId/website/brand-groups') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class WebsiteBrandGroupsController { + constructor(private readonly service: WebsiteBrandGroupsService) {} + + @Get() + @RequireBusinessPermission('website.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListWebsiteBrandGroupsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':groupId') + @RequireBusinessPermission('website.read') + getOne( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, groupId, user); + } + + @Post() + @RequireBusinessPermission('website.update') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateWebsiteBrandGroupDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':groupId') + @RequireBusinessPermission('website.update') + update( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @Body() dto: UpdateWebsiteBrandGroupDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, groupId, dto, user); + } + + @Delete(':groupId') + @RequireBusinessPermission('website.update') + remove( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, groupId, user); + } +} diff --git a/src/website/website-brand-groups.service.ts b/src/website/website-brand-groups.service.ts new file mode 100644 index 0000000..167bde4 --- /dev/null +++ b/src/website/website-brand-groups.service.ts @@ -0,0 +1,295 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateWebsiteBrandGroupDto, + ListWebsiteBrandGroupsDto, + UpdateWebsiteBrandGroupDto, +} from './dto/website-brand-groups.dto'; + +const groupInclude = { + website_brand_group_items: { + orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }], + include: { + brands: { + include: { imageMedia: true }, + }, + }, + }, +} satisfies Prisma.website_brand_groupsInclude; + +type GroupWithItems = Prisma.website_brand_groupsGetPayload<{ + include: typeof groupInclude; +}>; + +@Injectable() +export class WebsiteBrandGroupsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const groups = await this.prisma.website_brand_groups.findMany({ + where: { business_id: business.id, is_active: true }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + include: groupInclude, + }); + + return { + items: groups.map((group) => this.serializeGroup(group)), + }; + } + + async list( + businessIdRaw: string, + query: ListWebsiteBrandGroupsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.website_brand_groupsWhereInput = { + business_id: businessId, + ...(query.isActive !== undefined ? { is_active: query.isActive } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.website_brand_groups.findMany({ + where, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + skip, + take: pageSize, + include: groupInclude, + }), + this.prisma.website_brand_groups.count({ where }), + ]); + + return { + items: items.map((group) => this.serializeGroup(group)), + total, + page, + pageSize, + }; + } + + async getOne( + businessIdRaw: string, + groupIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const group = await this.findGroupOrThrow(businessId, groupId); + return { group: this.serializeGroup(group) }; + } + + async create( + businessIdRaw: string, + dto: CreateWebsiteBrandGroupDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + const brandIds = this.parseUniqueIds(dto.brandIds ?? []); + if (brandIds.length > 0) { + await this.assertBrandsBelongToBusiness(businessId, brandIds); + } + + const created = await this.prisma.$transaction(async (tx) => { + const group = await tx.website_brand_groups.create({ + data: { + business_id: businessId, + title: dto.title.trim(), + sort_order: dto.sortOrder ?? 0, + is_active: dto.isActive ?? true, + }, + }); + + await this.replaceItems(tx, group.id, brandIds); + return tx.website_brand_groups.findUniqueOrThrow({ + where: { id: group.id }, + include: groupInclude, + }); + }); + + return { + message: 'Website brand group created successfully', + group: this.serializeGroup(created), + }; + } + + async update( + businessIdRaw: string, + groupIdRaw: string, + dto: UpdateWebsiteBrandGroupDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findGroupOrThrow(businessId, groupId); + + let brandIds: bigint[] | undefined; + if (dto.brandIds !== undefined) { + brandIds = this.parseUniqueIds(dto.brandIds); + await this.assertBrandsBelongToBusiness(businessId, brandIds); + } + + const updated = await this.prisma.$transaction(async (tx) => { + await tx.website_brand_groups.update({ + where: { id: groupId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), + }, + }); + + if (brandIds !== undefined) { + await this.replaceItems(tx, groupId, brandIds); + } + + return tx.website_brand_groups.findUniqueOrThrow({ + where: { id: groupId }, + include: groupInclude, + }); + }); + + return { + message: 'Website brand group updated successfully', + group: this.serializeGroup(updated), + }; + } + + async remove( + businessIdRaw: string, + groupIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findGroupOrThrow(businessId, groupId); + await this.prisma.website_brand_groups.delete({ where: { id: groupId } }); + + return { message: 'Website brand group deleted successfully' }; + } + + private async findGroupOrThrow(businessId: bigint, groupId: bigint) { + const group = await this.prisma.website_brand_groups.findFirst({ + where: { id: groupId, business_id: businessId }, + include: groupInclude, + }); + + if (!group) { + throw new NotFoundException('Website brand group not found'); + } + + return group; + } + + private parseUniqueIds(ids: string[]) { + const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; + return unique.map((id) => BigInt(id)); + } + + private async assertBrandsBelongToBusiness( + businessId: bigint, + brandIds: bigint[], + ) { + const found = await this.prisma.brand.findMany({ + where: { businessId, id: { in: brandIds } }, + select: { id: true }, + }); + + if (found.length !== brandIds.length) { + throw new BadRequestException( + 'One or more brands were not found for this business', + ); + } + } + + private async replaceItems( + tx: Prisma.TransactionClient, + groupId: bigint, + brandIds: bigint[], + ) { + await tx.website_brand_group_items.deleteMany({ where: { group_id: groupId } }); + + if (brandIds.length === 0) { + return; + } + + await tx.website_brand_group_items.createMany({ + data: brandIds.map((brandId, index) => ({ + group_id: groupId, + brand_id: brandId, + sort_order: index, + })), + }); + } + + private serializeGroup(group: GroupWithItems) { + const items = group.website_brand_group_items.map((entry) => { + const brand = entry.brands; + return { + id: brand.id.toString(), + nameEn: brand.nameEn, + nameFa: brand.nameFa, + slug: brand.slug, + about: brand.about, + imageMediaId: brand.imageMediaId?.toString() ?? null, + imageUrl: brand.imageMedia?.publicUrl ?? null, + sortOrder: entry.sort_order, + }; + }); + + return { + id: group.id.toString(), + title: group.title, + sortOrder: group.sort_order, + isActive: group.is_active, + createdAt: group.created_at, + updatedAt: group.updated_at, + items, + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/website/website-business-info.controller.ts b/src/website/website-business-info.controller.ts new file mode 100644 index 0000000..385ddcf --- /dev/null +++ b/src/website/website-business-info.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { WebsiteBusinessInfoService } from './website-business-info.service'; + +@Controller('tenants/:host/website/business-info') +export class PublicWebsiteBusinessInfoController { + constructor(private readonly service: WebsiteBusinessInfoService) {} + + @Get() + get(@Param('host') host: string) { + return this.service.getPublic(host); + } +} diff --git a/src/website/website-business-info.service.ts b/src/website/website-business-info.service.ts new file mode 100644 index 0000000..759de4d --- /dev/null +++ b/src/website/website-business-info.service.ts @@ -0,0 +1,55 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + normalizeEmails, + normalizePhoneNumbers, + normalizeSocialMedia, +} from '../business-profile/business-profile.util'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; + +@Injectable() +export class WebsiteBusinessInfoService { + constructor( + private readonly prisma: PrismaService, + private readonly tenant: TenantService, + ) {} + + async getPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + + const record = await this.prisma.business.findUnique({ + where: { id: business.id }, + include: { + logoMedia: true, + faviconMedia: true, + addresses: { orderBy: { createdAt: 'asc' } }, + }, + }); + + if (!record) { + throw new NotFoundException('Business not found'); + } + + return { + id: record.id.toString(), + name: record.name, + nameFa: record.nameFa ?? '', + about: record.about ?? '', + vision: record.vision ?? '', + logoUrl: record.logoMedia?.publicUrl ?? null, + faviconUrl: + record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null, + emails: normalizeEmails(record.emails), + phoneNumbers: normalizePhoneNumbers(record.phoneNumbers), + socialMedia: normalizeSocialMedia(record.socialMedia), + addresses: record.addresses.map((address) => ({ + id: address.id.toString(), + province: address.province, + city: address.city, + address: address.address, + postalCode: address.postalCode, + landline: address.landline, + })), + }; + } +} diff --git a/src/website/website-category-groups.controller.ts b/src/website/website-category-groups.controller.ts new file mode 100644 index 0000000..b19132f --- /dev/null +++ b/src/website/website-category-groups.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateWebsiteCategoryGroupDto, + ListWebsiteCategoryGroupsDto, + UpdateWebsiteCategoryGroupDto, +} from './dto/website-category-groups.dto'; +import { WebsiteCategoryGroupsService } from './website-category-groups.service'; + +@Controller('tenants/:host/website/category-groups') +export class PublicWebsiteCategoryGroupsController { + constructor(private readonly service: WebsiteCategoryGroupsService) {} + + @Get() + list(@Param('host') host: string) { + return this.service.listPublic(host); + } +} + +@Controller('businesses/:businessId/website/category-groups') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class WebsiteCategoryGroupsController { + constructor(private readonly service: WebsiteCategoryGroupsService) {} + + @Get() + @RequireBusinessPermission('website.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListWebsiteCategoryGroupsDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':groupId') + @RequireBusinessPermission('website.read') + getOne( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, groupId, user); + } + + @Post() + @RequireBusinessPermission('website.update') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateWebsiteCategoryGroupDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':groupId') + @RequireBusinessPermission('website.update') + update( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @Body() dto: UpdateWebsiteCategoryGroupDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, groupId, dto, user); + } + + @Delete(':groupId') + @RequireBusinessPermission('website.update') + remove( + @Param('businessId') businessId: string, + @Param('groupId') groupId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, groupId, user); + } +} diff --git a/src/website/website-category-groups.service.ts b/src/website/website-category-groups.service.ts new file mode 100644 index 0000000..076ab7f --- /dev/null +++ b/src/website/website-category-groups.service.ts @@ -0,0 +1,294 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { MediaEntityType, Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateWebsiteCategoryGroupDto, + ListWebsiteCategoryGroupsDto, + UpdateWebsiteCategoryGroupDto, +} from './dto/website-category-groups.dto'; + +const groupInclude = { + website_category_group_items: { + orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }], + include: { + categories: true, + }, + }, +} satisfies Prisma.website_category_groupsInclude; + +type GroupWithItems = Prisma.website_category_groupsGetPayload<{ + include: typeof groupInclude; +}>; + +@Injectable() +export class WebsiteCategoryGroupsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const groups = await this.prisma.website_category_groups.findMany({ + where: { business_id: business.id, is_active: true }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + include: groupInclude, + }); + + return { + items: groups.map((group) => this.serializeGroup(group, true)), + }; + } + + async list( + businessIdRaw: string, + query: ListWebsiteCategoryGroupsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.website_category_groupsWhereInput = { + business_id: businessId, + ...(query.isActive !== undefined ? { is_active: query.isActive } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.website_category_groups.findMany({ + where, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + skip, + take: pageSize, + include: groupInclude, + }), + this.prisma.website_category_groups.count({ where }), + ]); + + return { + items: items.map((group) => this.serializeGroup(group, false)), + total, + page, + pageSize, + }; + } + + async getOne( + businessIdRaw: string, + groupIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const group = await this.findGroupOrThrow(businessId, groupId); + return { group: this.serializeGroup(group, false) }; + } + + async create( + businessIdRaw: string, + dto: CreateWebsiteCategoryGroupDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + const categoryIds = this.parseUniqueIds(dto.categoryIds ?? []); + if (categoryIds.length > 0) { + await this.assertCategoriesBelongToBusiness(businessId, categoryIds); + } + + const created = await this.prisma.$transaction(async (tx) => { + const group = await tx.website_category_groups.create({ + data: { + business_id: businessId, + title: dto.title.trim(), + sort_order: dto.sortOrder ?? 0, + is_active: dto.isActive ?? true, + }, + }); + + await this.replaceItems(tx, group.id, categoryIds); + return tx.website_category_groups.findUniqueOrThrow({ + where: { id: group.id }, + include: groupInclude, + }); + }); + + return { + message: 'Website category group created successfully', + group: this.serializeGroup(created, false), + }; + } + + async update( + businessIdRaw: string, + groupIdRaw: string, + dto: UpdateWebsiteCategoryGroupDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findGroupOrThrow(businessId, groupId); + + let categoryIds: bigint[] | undefined; + if (dto.categoryIds !== undefined) { + categoryIds = this.parseUniqueIds(dto.categoryIds); + await this.assertCategoriesBelongToBusiness(businessId, categoryIds); + } + + const updated = await this.prisma.$transaction(async (tx) => { + await tx.website_category_groups.update({ + where: { id: groupId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), + }, + }); + + if (categoryIds !== undefined) { + await this.replaceItems(tx, groupId, categoryIds); + } + + return tx.website_category_groups.findUniqueOrThrow({ + where: { id: groupId }, + include: groupInclude, + }); + }); + + return { + message: 'Website category group updated successfully', + group: this.serializeGroup(updated, false), + }; + } + + async remove( + businessIdRaw: string, + groupIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const groupId = BigInt(groupIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findGroupOrThrow(businessId, groupId); + await this.prisma.website_category_groups.delete({ where: { id: groupId } }); + + return { message: 'Website category group deleted successfully' }; + } + + private async findGroupOrThrow(businessId: bigint, groupId: bigint) { + const group = await this.prisma.website_category_groups.findFirst({ + where: { id: groupId, business_id: businessId }, + include: groupInclude, + }); + + if (!group) { + throw new NotFoundException('Website category group not found'); + } + + return group; + } + + private parseUniqueIds(ids: string[]) { + const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))]; + return unique.map((id) => BigInt(id)); + } + + private async assertCategoriesBelongToBusiness( + businessId: bigint, + categoryIds: bigint[], + ) { + const found = await this.prisma.category.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + id: { in: categoryIds }, + }, + select: { id: true }, + }); + + if (found.length !== categoryIds.length) { + throw new BadRequestException( + 'One or more product categories were not found for this business', + ); + } + } + + private async replaceItems( + tx: Prisma.TransactionClient, + groupId: bigint, + categoryIds: bigint[], + ) { + await tx.website_category_group_items.deleteMany({ where: { group_id: groupId } }); + + if (categoryIds.length === 0) { + return; + } + + await tx.website_category_group_items.createMany({ + data: categoryIds.map((categoryId, index) => ({ + group_id: groupId, + category_id: categoryId, + sort_order: index, + })), + }); + } + + private serializeGroup(group: GroupWithItems, publicView: boolean) { + const items = group.website_category_group_items + .filter((entry) => !publicView || entry.categories.isActive) + .map((entry) => ({ + id: entry.categories.id.toString(), + name: entry.categories.name, + nameFa: entry.categories.nameFa, + slug: entry.categories.slug, + description: entry.categories.description, + sortOrder: entry.sort_order, + })); + + return { + id: group.id.toString(), + title: group.title, + sortOrder: group.sort_order, + isActive: group.is_active, + createdAt: group.created_at, + updatedAt: group.updated_at, + items, + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/website/website-sliders.controller.ts b/src/website/website-sliders.controller.ts new file mode 100644 index 0000000..b7365d8 --- /dev/null +++ b/src/website/website-sliders.controller.ts @@ -0,0 +1,89 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; +import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CreateWebsiteSliderDto, + ListWebsiteSlidersDto, + UpdateWebsiteSliderDto, +} from './dto/website-sliders.dto'; +import { WebsiteSlidersService } from './website-sliders.service'; + +@Controller('tenants/:host/website/sliders') +export class PublicWebsiteSlidersController { + constructor(private readonly service: WebsiteSlidersService) {} + + @Get() + list(@Param('host') host: string) { + return this.service.listPublic(host); + } +} + +@Controller('businesses/:businessId/website/sliders') +@UseGuards(JwtAuthGuard, BusinessPermissionGuard) +export class WebsiteSlidersController { + constructor(private readonly service: WebsiteSlidersService) {} + + @Get() + @RequireBusinessPermission('website.read') + list( + @Param('businessId') businessId: string, + @Query() query: ListWebsiteSlidersDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.list(businessId, query, user); + } + + @Get(':sliderId') + @RequireBusinessPermission('website.read') + getOne( + @Param('businessId') businessId: string, + @Param('sliderId') sliderId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getOne(businessId, sliderId, user); + } + + @Post() + @RequireBusinessPermission('website.update') + create( + @Param('businessId') businessId: string, + @Body() dto: CreateWebsiteSliderDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.create(businessId, dto, user); + } + + @Patch(':sliderId') + @RequireBusinessPermission('website.update') + update( + @Param('businessId') businessId: string, + @Param('sliderId') sliderId: string, + @Body() dto: UpdateWebsiteSliderDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.update(businessId, sliderId, dto, user); + } + + @Delete(':sliderId') + @RequireBusinessPermission('website.update') + remove( + @Param('businessId') businessId: string, + @Param('sliderId') sliderId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.remove(businessId, sliderId, user); + } +} diff --git a/src/website/website-sliders.service.ts b/src/website/website-sliders.service.ts new file mode 100644 index 0000000..7caa1ca --- /dev/null +++ b/src/website/website-sliders.service.ts @@ -0,0 +1,303 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { TenantService } from '../tenant/tenant.service'; +import { + CreateWebsiteSliderDto, + ListWebsiteSlidersDto, + UpdateWebsiteSliderDto, + WebsiteSliderSlideInputDto, +} from './dto/website-sliders.dto'; + +const sliderInclude = { + website_slider_slides: { + orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }], + include: { + media: true, + }, + }, +} satisfies Prisma.website_slidersInclude; + +type SliderWithSlides = Prisma.website_slidersGetPayload<{ + include: typeof sliderInclude; +}>; + +@Injectable() +export class WebsiteSlidersService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const sliders = await this.prisma.website_sliders.findMany({ + where: { business_id: business.id, is_active: true }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + include: sliderInclude, + }); + + return { + items: sliders.map((slider) => this.serializeSlider(slider, true)), + }; + } + + async list( + businessIdRaw: string, + query: ListWebsiteSlidersDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.website_slidersWhereInput = { + business_id: businessId, + ...(query.isActive !== undefined ? { is_active: query.isActive } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.website_sliders.findMany({ + where, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + skip, + take: pageSize, + include: sliderInclude, + }), + this.prisma.website_sliders.count({ where }), + ]); + + return { + items: items.map((slider) => this.serializeSlider(slider, false)), + total, + page, + pageSize, + }; + } + + async getOne( + businessIdRaw: string, + sliderIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const sliderId = BigInt(sliderIdRaw); + await this.assertPermission(businessId, actor.id, 'website.read'); + + const slider = await this.findSliderOrThrow(businessId, sliderId); + return { slider: this.serializeSlider(slider, false) }; + } + + async create( + businessIdRaw: string, + dto: CreateWebsiteSliderDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + const slides = dto.slides ?? []; + await this.assertSlideMedia(businessId, slides); + + const created = await this.prisma.$transaction(async (tx) => { + const slider = await tx.website_sliders.create({ + data: { + business_id: businessId, + title: dto.title.trim(), + sort_order: dto.sortOrder ?? 0, + is_active: dto.isActive ?? true, + }, + }); + + await this.replaceSlides(tx, slider.id, slides); + return tx.website_sliders.findUniqueOrThrow({ + where: { id: slider.id }, + include: sliderInclude, + }); + }); + + return { + message: 'Website slider created successfully', + slider: this.serializeSlider(created, false), + }; + } + + async update( + businessIdRaw: string, + sliderIdRaw: string, + dto: UpdateWebsiteSliderDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const sliderId = BigInt(sliderIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findSliderOrThrow(businessId, sliderId); + + let slides: WebsiteSliderSlideInputDto[] | undefined; + if (dto.slides !== undefined) { + slides = dto.slides; + await this.assertSlideMedia(businessId, slides); + } + + const updated = await this.prisma.$transaction(async (tx) => { + await tx.website_sliders.update({ + where: { id: sliderId }, + data: { + ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), + ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), + ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), + }, + }); + + if (slides !== undefined) { + await this.replaceSlides(tx, sliderId, slides); + } + + return tx.website_sliders.findUniqueOrThrow({ + where: { id: sliderId }, + include: sliderInclude, + }); + }); + + return { + message: 'Website slider updated successfully', + slider: this.serializeSlider(updated, false), + }; + } + + async remove( + businessIdRaw: string, + sliderIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const sliderId = BigInt(sliderIdRaw); + await this.assertPermission(businessId, actor.id, 'website.update'); + + await this.findSliderOrThrow(businessId, sliderId); + await this.prisma.website_sliders.delete({ where: { id: sliderId } }); + + return { message: 'Website slider deleted successfully' }; + } + + private async findSliderOrThrow(businessId: bigint, sliderId: bigint) { + const slider = await this.prisma.website_sliders.findFirst({ + where: { id: sliderId, business_id: businessId }, + include: sliderInclude, + }); + + if (!slider) { + throw new NotFoundException('Website slider not found'); + } + + return slider; + } + + private async assertSlideMedia( + businessId: bigint, + slides: WebsiteSliderSlideInputDto[], + ) { + if (slides.length === 0) { + return; + } + + const mediaIds = slides.map((slide) => BigInt(slide.imageMediaId)); + const uniqueMediaIds = [...new Set(mediaIds.map((id) => id.toString()))].map( + (id) => BigInt(id), + ); + + const found = await this.prisma.media.findMany({ + where: { + businessId, + id: { in: uniqueMediaIds }, + mimeType: { startsWith: 'image/' }, + }, + select: { id: true }, + }); + + if (found.length !== uniqueMediaIds.length) { + throw new BadRequestException( + 'One or more slide images were not found for this business', + ); + } + } + + private async replaceSlides( + tx: Prisma.TransactionClient, + sliderId: bigint, + slides: WebsiteSliderSlideInputDto[], + ) { + await tx.website_slider_slides.deleteMany({ where: { slider_id: sliderId } }); + + if (slides.length === 0) { + return; + } + + await tx.website_slider_slides.createMany({ + data: slides.map((slide, index) => ({ + slider_id: sliderId, + image_media_id: BigInt(slide.imageMediaId), + title: slide.title?.trim() || null, + link_url: slide.linkUrl?.trim() || null, + sort_order: index, + is_active: slide.isActive ?? true, + })), + }); + } + + private serializeSlider(slider: SliderWithSlides, publicView: boolean) { + const slides = slider.website_slider_slides + .filter((slide) => !publicView || slide.is_active) + .map((slide) => ({ + id: slide.id.toString(), + imageMediaId: slide.image_media_id.toString(), + imageUrl: slide.media.publicUrl, + title: slide.title, + linkUrl: slide.link_url, + sortOrder: slide.sort_order, + isActive: slide.is_active, + createdAt: slide.created_at, + updatedAt: slide.updated_at, + })); + + return { + id: slider.id.toString(), + title: slider.title, + sortOrder: slider.sort_order, + isActive: slider.is_active, + createdAt: slider.created_at, + updatedAt: slider.updated_at, + slides, + }; + } + + private async assertPermission( + businessId: bigint, + userId: bigint, + permission: string, + ) { + const allowed = await this.permissions.hasBusinessPermission( + userId, + businessId, + permission, + ); + + if (!allowed) { + throw new ForbiddenException( + `Missing permission: ${permission} for this business`, + ); + } + } +} diff --git a/src/website/website.module.ts b/src/website/website.module.ts new file mode 100644 index 0000000..4459fcc --- /dev/null +++ b/src/website/website.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TenantModule } from '../tenant/tenant.module'; +import { + PublicWebsiteBrandGroupsController, + WebsiteBrandGroupsController, +} from './website-brand-groups.controller'; +import { WebsiteBrandGroupsService } from './website-brand-groups.service'; +import { + PublicWebsiteCategoryGroupsController, + WebsiteCategoryGroupsController, +} from './website-category-groups.controller'; +import { WebsiteCategoryGroupsService } from './website-category-groups.service'; +import { + PublicWebsiteBusinessInfoController, +} from './website-business-info.controller'; +import { WebsiteBusinessInfoService } from './website-business-info.service'; +import { + PublicWebsiteSlidersController, + WebsiteSlidersController, +} from './website-sliders.controller'; +import { WebsiteSlidersService } from './website-sliders.service'; + +@Module({ + imports: [AuthModule, TenantModule], + controllers: [ + PublicWebsiteCategoryGroupsController, + WebsiteCategoryGroupsController, + PublicWebsiteBrandGroupsController, + WebsiteBrandGroupsController, + PublicWebsiteSlidersController, + WebsiteSlidersController, + PublicWebsiteBusinessInfoController, + ], + providers: [ + WebsiteCategoryGroupsService, + WebsiteBrandGroupsService, + WebsiteSlidersService, + WebsiteBusinessInfoService, + ], +}) +export class WebsiteModule {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..639a82b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}